diff --git a/.github/workflows/python-check.yml b/.github/workflows/python-check.yml index 0ffc826..563284f 100644 --- a/.github/workflows/python-check.yml +++ b/.github/workflows/python-check.yml @@ -67,6 +67,7 @@ jobs: TESTING: 1 MOVIE_CATALOG__REDIS__CONNECTION__HOST: redis MOVIE_CATALOG__REDIS__CONNECTION__PORT: 6379 + MOVIE_CATALOG__SESSION__SECRET_KEY: "abc" - name: Upload artefacts uses: actions/upload-artifact@v4 diff --git a/movie_catalog/api/api_v1/auth/services/__init__.py b/movie_catalog/api/api_v1/auth/services/__init__.py deleted file mode 100644 index 6312fd5..0000000 --- a/movie_catalog/api/api_v1/auth/services/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -__all__ = ["redis_tokens", "redis_users"] -from .redis_tokens_helper import redis_tokens -from .redis_users_helper import redis_users diff --git a/movie_catalog/api/api_v1/movie_catalog/dependencies.py b/movie_catalog/api/api_v1/movie_catalog/dependencies.py index 9005412..b087072 100644 --- a/movie_catalog/api/api_v1/movie_catalog/dependencies.py +++ b/movie_catalog/api/api_v1/movie_catalog/dependencies.py @@ -4,47 +4,21 @@ from fastapi import Depends, HTTPException, status from fastapi.security import ( HTTPAuthorizationCredentials, - HTTPBasic, HTTPBasicCredentials, HTTPBearer, ) -from movie_catalog.schemas.movie_catalog import Movie - -from ..auth.services import redis_tokens, redis_users -from .crud import storage +from movie_catalog.dependencies.auth import user_basic_auth, validate_basic_auth +from movie_catalog.services.auth import redis_tokens logger = logging.getLogger(__name__) -UNSAFE_METHOD = frozenset( - { - "POST", - "PUT", - "PATCH", - "DELETE", - } -) static_api_token = HTTPBearer( scheme_name="Static API token", description="Your **Static API token** from the developer portal. [Read more](#)", auto_error=False, ) -user_basic_auth = HTTPBasic( - scheme_name="Basic auth", - description="Basic username + password auth", - auto_error=False, -) - - -def prefetch_film(slug: str) -> Movie: - films: Movie | None = storage.get_by_slug(slug) - if films: - return films - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=f"Movie {slug!r} not found" - ) - def validate_api_token(api_token: HTTPAuthorizationCredentials) -> None: logger.debug("API token: %s", api_token) @@ -71,29 +45,6 @@ def api_token_required( validate_api_token(api_token=api_token) -def validate_basic_auth(credentials: HTTPBasicCredentials | None) -> None: - logger.debug("User credentials: %s", credentials) - - if credentials and redis_users.validate_user_password( - username=credentials.username, password=credentials.password - ): - return - - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password.", - headers={"WWW-Authenticate": "Basic"}, - ) - - -def user_basic_auth_required( - credentials: Annotated[ - HTTPBasicCredentials | None, Depends(user_basic_auth) - ] = None, -) -> None: - validate_basic_auth(credentials=credentials) - - def api_token_or_user_basic_auth_required( api_token: Annotated[ HTTPAuthorizationCredentials | None, Depends(static_api_token) diff --git a/movie_catalog/api/api_v1/movie_catalog/views/details_view.py b/movie_catalog/api/api_v1/movie_catalog/views/details_view.py index acfe879..e971749 100644 --- a/movie_catalog/api/api_v1/movie_catalog/views/details_view.py +++ b/movie_catalog/api/api_v1/movie_catalog/views/details_view.py @@ -1,18 +1,16 @@ -from typing import Annotated - from fastapi import APIRouter, Depends, status -from movie_catalog.api.api_v1.movie_catalog.crud import storage from movie_catalog.api.api_v1.movie_catalog.dependencies import ( api_token_or_user_basic_auth_required, - prefetch_film, ) +from movie_catalog.dependencies.movie_catalog import MovieBySlug from movie_catalog.schemas.movie_catalog import ( Movie, MoviePartialUpdate, MovieRead, MovieUpdate, ) +from movie_catalog.storage.movie_catalog.crud import storage router = APIRouter( prefix="/{slug}", @@ -50,8 +48,6 @@ }, ) -MovieBySlug = Annotated[Movie, Depends(prefetch_film)] - @router.get("/", response_model=MovieRead) def get_movie(movie: MovieBySlug) -> Movie: diff --git a/movie_catalog/api/api_v1/movie_catalog/views/list_view.py b/movie_catalog/api/api_v1/movie_catalog/views/list_view.py index 47b58cf..b9cc5f0 100644 --- a/movie_catalog/api/api_v1/movie_catalog/views/list_view.py +++ b/movie_catalog/api/api_v1/movie_catalog/views/list_view.py @@ -2,14 +2,14 @@ from fastapi import APIRouter, Depends, HTTPException, status -from movie_catalog.api.api_v1.movie_catalog.crud import ( - MovieCatalogAlreadyExists, - storage, -) from movie_catalog.api.api_v1.movie_catalog.dependencies import ( api_token_or_user_basic_auth_required, ) +from movie_catalog.exceptions import MovieAlreadyExists from movie_catalog.schemas.movie_catalog import Movie, MovieCreate, MovieRead +from movie_catalog.storage.movie_catalog.crud import ( + storage, +) router: APIRouter = APIRouter( prefix="/movies", @@ -70,7 +70,7 @@ def add_movie( ) -> Movie: try: return storage.create_or_rise_if_exists(movie_create) - except MovieCatalogAlreadyExists: + except MovieAlreadyExists: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Movie with slug <{movie_create.slug}> already exists.", diff --git a/movie_catalog/api/main_view.py b/movie_catalog/api/main_view.py deleted file mode 100644 index e4f7b14..0000000 --- a/movie_catalog/api/main_view.py +++ /dev/null @@ -1,14 +0,0 @@ -from fastapi import APIRouter, Request - -router = APIRouter() - - -@router.get("/") -def root( - request: Request, -) -> dict[str, str]: - docs_url = request.url.replace(path="/docs") - return { - "message": "Hello World", - "docs_url": str(docs_url), - } diff --git a/movie_catalog/app_lifespan.py b/movie_catalog/app_lifespan.py index 930a567..9f04ec0 100644 --- a/movie_catalog/app_lifespan.py +++ b/movie_catalog/app_lifespan.py @@ -3,10 +3,15 @@ from fastapi import FastAPI +from movie_catalog.config import settings +from movie_catalog.storage.movie_catalog import MovieCatalogStorage + @asynccontextmanager async def lifespan( app: FastAPI, # noqa: ARG001 ) -> AsyncIterator[None]: - + app.state.movie_catalog_storage = MovieCatalogStorage( + hast_name=settings.redis.collections.movie_catalog_hash, + ) yield diff --git a/movie_catalog/commands/tokens.py b/movie_catalog/commands/tokens.py index d801556..0320731 100644 --- a/movie_catalog/commands/tokens.py +++ b/movie_catalog/commands/tokens.py @@ -2,9 +2,9 @@ from typing import Annotated import typer -from api.api_v1.auth.services import redis_tokens from rich import print from rich.markdown import Markdown +from services.auth import redis_tokens app = typer.Typer( name="token", diff --git a/movie_catalog/config.default.yaml b/movie_catalog/config.default.yaml index 8837e6f..0cb3bac 100644 --- a/movie_catalog/config.default.yaml +++ b/movie_catalog/config.default.yaml @@ -1,3 +1,5 @@ movie-catalog: logging: log_level_name: INFO + session: + secret_key: diff --git a/movie_catalog/config.py b/movie_catalog/config.py index 277d1c8..c1bb862 100644 --- a/movie_catalog/config.py +++ b/movie_catalog/config.py @@ -55,6 +55,10 @@ class RedisConfig(BaseModel): collections: RedisCollectionsNamesConfig = RedisCollectionsNamesConfig() +class SessionConfig(BaseModel): + secret_key: str + + class Settings(BaseSettings): model_config = SettingsConfigDict( case_sensitive=False, @@ -103,6 +107,7 @@ def settings_customise_sources( logging: LoggingConfig = LoggingConfig() redis: RedisConfig = RedisConfig() + session: SessionConfig settings = Settings() diff --git a/movie_catalog/api/api_v1/auth/__init__.py b/movie_catalog/dependencies/__init__.py similarity index 100% rename from movie_catalog/api/api_v1/auth/__init__.py rename to movie_catalog/dependencies/__init__.py diff --git a/movie_catalog/dependencies/auth.py b/movie_catalog/dependencies/auth.py new file mode 100644 index 0000000..5e0e501 --- /dev/null +++ b/movie_catalog/dependencies/auth.py @@ -0,0 +1,52 @@ +import logging +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from starlette.requests import Request + +from movie_catalog.services.auth import redis_users + +logger = logging.getLogger(__name__) + + +UNSAFE_METHOD = frozenset( + { + "POST", + "PUT", + "PATCH", + "DELETE", + } +) +user_basic_auth = HTTPBasic( + scheme_name="Basic auth", + description="Basic username + password auth", + auto_error=False, +) + + +def validate_basic_auth(credentials: HTTPBasicCredentials | None) -> None: + logger.debug("User credentials: %s", credentials) + + if credentials and redis_users.validate_user_password( + username=credentials.username, password=credentials.password + ): + return + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password.", + headers={"WWW-Authenticate": "Basic"}, + ) + + +def user_basic_auth_required_for_unsafe_methods( + request: Request, + credentials: Annotated[ + HTTPBasicCredentials | None, Depends(user_basic_auth) + ] = None, +) -> None: + if request.method not in UNSAFE_METHOD: + return + + validate_basic_auth(credentials=credentials) diff --git a/movie_catalog/dependencies/movie_catalog.py b/movie_catalog/dependencies/movie_catalog.py new file mode 100644 index 0000000..57dd963 --- /dev/null +++ b/movie_catalog/dependencies/movie_catalog.py @@ -0,0 +1,32 @@ +from typing import Annotated + +from fastapi import Depends, HTTPException, Request +from starlette import status + +from movie_catalog.schemas.movie_catalog import Movie +from movie_catalog.storage.movie_catalog import MovieCatalogStorage +from movie_catalog.storage.movie_catalog.crud import storage + + +def get_movie_catalog_storage( + request: Request, +) -> MovieCatalogStorage: + return request.app.state.movie_catalog_storage # type: ignore[no-any-return] + + +GetMovieCatalogStorage = Annotated[ + MovieCatalogStorage, + Depends(get_movie_catalog_storage), +] + + +def prefetch_movie(slug: str) -> Movie: + movie: Movie | None = storage.get_by_slug(slug) + if movie: + return movie + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=f"Movie {slug!r} not found" + ) + + +MovieBySlug = Annotated[Movie, Depends(prefetch_movie)] diff --git a/movie_catalog/exceptions.py b/movie_catalog/exceptions.py new file mode 100644 index 0000000..7047442 --- /dev/null +++ b/movie_catalog/exceptions.py @@ -0,0 +1,10 @@ +class MovieCatalogBaseError(Exception): + """ + Base exception class for Movie Catalog + """ + + +class MovieAlreadyExists(MovieCatalogBaseError): + """ + Exception raised when a movie already exists + """ diff --git a/movie_catalog/main.py b/movie_catalog/main.py index eb5f09b..0c00ca1 100644 --- a/movie_catalog/main.py +++ b/movie_catalog/main.py @@ -1,11 +1,12 @@ import logging from fastapi import FastAPI +from starlette.middleware.sessions import SessionMiddleware from movie_catalog.api import router as api_router -from movie_catalog.api.main_view import router as main_router from movie_catalog.app_lifespan import lifespan from movie_catalog.config import settings +from movie_catalog.rest import router as rest_router logging.basicConfig( format=settings.logging.log_format, @@ -19,5 +20,9 @@ version="1.0", lifespan=lifespan, ) -app.include_router(main_router) +app.add_middleware( + SessionMiddleware, + secret_key=settings.session.secret_key, +) +app.include_router(rest_router) app.include_router(api_router) diff --git a/movie_catalog/manage.bat b/movie_catalog/manage.bat index e70cb1e..f13cfb5 100644 --- a/movie_catalog/manage.bat +++ b/movie_catalog/manage.bat @@ -1,2 +1,2 @@ @echo off -uv run --script manage.py %* +uv run --script movie_catalog/manage.py %* diff --git a/movie_catalog/misc/__init__.py b/movie_catalog/misc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/movie_catalog/misc/flash_messages.py b/movie_catalog/misc/flash_messages.py new file mode 100644 index 0000000..0d58c55 --- /dev/null +++ b/movie_catalog/misc/flash_messages.py @@ -0,0 +1,26 @@ +from typing import TypedDict + +from fastapi import Request + +FLASHED_MESSAGES_KEY = "_flashed_messages" + + +class Message(TypedDict): + message: str + category: str + + +def flash(request: Request, message: str, category: str = "info") -> None: + if FLASHED_MESSAGES_KEY not in request.session: + request.session[FLASHED_MESSAGES_KEY] = [] + + request.session[FLASHED_MESSAGES_KEY].append( + Message( + message=message, + category=category, + ) + ) + + +def get_flashed_messages(request: Request) -> list[Message]: + return request.session.pop(FLASHED_MESSAGES_KEY, []) # type: ignore[no-any-return] diff --git a/movie_catalog/rest/__init__.py b/movie_catalog/rest/__init__.py new file mode 100644 index 0000000..a477dda --- /dev/null +++ b/movie_catalog/rest/__init__.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from .main_view import router as main_router +from .movie_catalog import router as movie_catalog_router + +router = APIRouter( + include_in_schema=False, +) +router.include_router(main_router) +router.include_router(movie_catalog_router) diff --git a/movie_catalog/rest/main_view.py b/movie_catalog/rest/main_view.py new file mode 100644 index 0000000..b5a78f4 --- /dev/null +++ b/movie_catalog/rest/main_view.py @@ -0,0 +1,34 @@ +from typing import Any + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from movie_catalog.templating.jinja_templates import templates + +router = APIRouter() + + +@router.get("/", include_in_schema=False, name="home") +def home_page( + request: Request, +) -> HTMLResponse: + + context: dict[str, Any] = {} + features = { + "🚀 Fast API": "High performance with automatic OpenAPI documentation generation.", + "⭐ Movie Ratings": "Rate movies and build your own catalog of the best ones.", + "️🛠️ Easy Integration": "Clean and simple codebase that is easy to extend and improve.", + } + context.update( + features=features, + ) + return templates.TemplateResponse( + request=request, name="home.html", context=context + ) + + +@router.get("/about", include_in_schema=False, name="about") +def about_page( + request: Request, +) -> HTMLResponse: + return templates.TemplateResponse(request=request, name="about.html") diff --git a/movie_catalog/rest/movie_catalog/__init__.py b/movie_catalog/rest/movie_catalog/__init__.py new file mode 100644 index 0000000..2156304 --- /dev/null +++ b/movie_catalog/rest/movie_catalog/__init__.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends + +from movie_catalog.dependencies.auth import user_basic_auth_required_for_unsafe_methods + +from .create_view import router as create_router +from .delete_view import router as delete_router +from .list_view import router as list_router +from .update_view import router as update_router + +router = APIRouter( + prefix="/movie-catalog", + tags=["Movie Catalog REST"], + dependencies=[ + Depends(user_basic_auth_required_for_unsafe_methods), + ], +) +router.include_router(list_router) +router.include_router(create_router) +router.include_router(update_router) +router.include_router(delete_router) diff --git a/movie_catalog/rest/movie_catalog/create_view.py b/movie_catalog/rest/movie_catalog/create_view.py new file mode 100644 index 0000000..958ff06 --- /dev/null +++ b/movie_catalog/rest/movie_catalog/create_view.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse +from pydantic import ValidationError + +from movie_catalog.dependencies.movie_catalog import GetMovieCatalogStorage +from movie_catalog.exceptions import MovieAlreadyExists +from movie_catalog.misc.flash_messages import flash +from movie_catalog.schemas.movie_catalog import MovieCreate +from movie_catalog.services.movie_catalog.form_response_helper import FormResponseHelper + +router = APIRouter( + prefix="/create", +) + +form_response = FormResponseHelper( + model=MovieCreate, + template_name="movie-catalog/create.html", +) + + +@router.get("/", name="movie-catalog:create-view") +def get_page_add_movie_to_catalog(request: Request) -> HTMLResponse: + return form_response.render(request) + + +@router.post("/", name="movie-catalog:create", response_model=None) +async def add_movie( + request: Request, storage: GetMovieCatalogStorage +) -> RedirectResponse | HTMLResponse: + async with request.form() as form: + try: + movie_create = MovieCreate.model_validate(form) + except ValidationError as e: + return form_response.render( + request=request, + form_data=form, + pydantic_error=e, + form_validated=True, + ) + + try: + storage.create_or_rise_if_exists(movie_create) + except MovieAlreadyExists: + errors = { + "slug": f"Movie with slug '{movie_create.slug}' already exists.", + } + else: + flash( + request=request, + message=f"You added {movie_create.title!r} to the rating!", + category="success", + ) + request.session["message"] = f"You added {movie_create.title!r} to the rating!" + return RedirectResponse( + url=request.url_for("movie-catalog:list"), + status_code=status.HTTP_303_SEE_OTHER, + ) + return form_response.render( + request=request, + errors=errors, + form_data=movie_create, + form_validated=True, + ) diff --git a/movie_catalog/rest/movie_catalog/delete_view.py b/movie_catalog/rest/movie_catalog/delete_view.py new file mode 100644 index 0000000..80692a2 --- /dev/null +++ b/movie_catalog/rest/movie_catalog/delete_view.py @@ -0,0 +1,30 @@ +import logging + +from fastapi import APIRouter, Request, Response, status +from starlette.requests import Request + +from movie_catalog.dependencies.movie_catalog import GetMovieCatalogStorage, MovieBySlug +from movie_catalog.misc.flash_messages import flash + +logger = logging.getLogger(__name__) +router = APIRouter( + prefix="/{slug}/delete", +) + + +@router.delete("/", name="movie-catalog:delete", response_model=None) +async def delete_movie( + request: Request, + movie: MovieBySlug, + storage: GetMovieCatalogStorage, +) -> Response: + storage.delete(movie) + flash( + request=request, + message=f"Movie {movie.title!r} has been deleted.", + category="danger", + ) + return Response( + status_code=status.HTTP_200_OK, + content="", + ) diff --git a/movie_catalog/rest/movie_catalog/list_view.py b/movie_catalog/rest/movie_catalog/list_view.py new file mode 100644 index 0000000..9501441 --- /dev/null +++ b/movie_catalog/rest/movie_catalog/list_view.py @@ -0,0 +1,21 @@ +from typing import Any + +from fastapi import APIRouter, Request +from starlette.responses import HTMLResponse + +from movie_catalog.dependencies.movie_catalog import GetMovieCatalogStorage +from movie_catalog.templating import templates + +router = APIRouter() + + +@router.get("/", name="movie-catalog:list") +def list_view(request: Request, storage: GetMovieCatalogStorage) -> HTMLResponse: + context: dict[str, Any] = {} + movie_catalog = storage.get() + context.update(movie_catalog=movie_catalog) + return templates.TemplateResponse( + request=request, + name="movie-catalog/list.html", + context=context, + ) diff --git a/movie_catalog/rest/movie_catalog/update_view.py b/movie_catalog/rest/movie_catalog/update_view.py new file mode 100644 index 0000000..705136e --- /dev/null +++ b/movie_catalog/rest/movie_catalog/update_view.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse +from pydantic import ValidationError + +from movie_catalog.dependencies.movie_catalog import GetMovieCatalogStorage, MovieBySlug +from movie_catalog.misc.flash_messages import flash +from movie_catalog.schemas.movie_catalog import MovieUpdate +from movie_catalog.services.movie_catalog.form_response_helper import FormResponseHelper + +router = APIRouter( + prefix="/{slug}/update", +) + +form_response = FormResponseHelper( + model=MovieUpdate, + template_name="movie-catalog/update.html", +) + + +@router.get("/", name="movie-catalog:update-view") +def get_page_update_movie_to_catalog( + request: Request, + movie: MovieBySlug, +) -> HTMLResponse: + form = MovieUpdate(**movie.model_dump()) + return form_response.render( + request, + form_data=form, + movie=movie, + ) + + +@router.post("/", name="movie-catalog:update", response_model=None) +async def update_movie( + request: Request, + movie: MovieBySlug, + storage: GetMovieCatalogStorage, +) -> RedirectResponse | HTMLResponse: + async with request.form() as form: + try: + movie_update = MovieUpdate.model_validate(form) + except ValidationError as e: + return form_response.render( + request=request, + form_data=form, + pydantic_error=e, + form_validated=True, + movie=movie, + ) + + storage.update(movie, movie_update) + flash( + request=request, + message=f"Movie {movie_update.title!r} has been updated.", + category="success", + ) + return RedirectResponse( + url=request.url_for("movie-catalog:list"), + status_code=status.HTTP_303_SEE_OTHER, + ) diff --git a/movie_catalog/schemas/movie_catalog.py b/movie_catalog/schemas/movie_catalog.py index 260ecb8..45789d4 100644 --- a/movie_catalog/schemas/movie_catalog.py +++ b/movie_catalog/schemas/movie_catalog.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import AnyHttpUrl, BaseModel, Field DESCRIPTION_MAX_LENGTH = 500 DESCRIPTION_MIN_LENGTH = 20 @@ -9,6 +9,7 @@ class MovieBase(BaseModel): description: str year_released: int rating: float + original_link: AnyHttpUrl | None = None class MovieCreate(MovieBase): @@ -20,17 +21,17 @@ class MovieCreate(MovieBase): ..., min_length=3, max_length=50, - title="Movie slug", + title="Slug", ) title: str = Field( ..., min_length=3, max_length=50, - title="Movie title", + title="Title", ) description: str = Field( ..., - title="Movie description", + title="Description", min_length=DESCRIPTION_MIN_LENGTH, max_length=DESCRIPTION_MAX_LENGTH, ) @@ -44,8 +45,9 @@ class MovieCreate(MovieBase): 1.0, ge=0.0, le=10.0, - title="Movie rating", + title="Rating", ) + original_link: AnyHttpUrl | None = None class MovieUpdate(MovieBase): @@ -57,11 +59,11 @@ class MovieUpdate(MovieBase): ..., min_length=3, max_length=50, - title="Movie title", + title="Title", ) description: str = Field( ..., - title="Movie description", + title="Description", min_length=20, max_length=500, ) @@ -75,6 +77,37 @@ class MovieUpdate(MovieBase): 1.0, ge=0.0, le=10.0, + title="Rating", + ) + + +class MovieUpdateForm(MovieBase): + """ + Модель для обновления фильма в форме + """ + + title: str = Field( + ..., + min_length=3, + max_length=50, + title="Movie title", + ) + description: str = Field( + ..., + title="Movie description", + min_length=20, + max_length=500, + ) + year_released: int = Field( + 1900, + ge=0, + le=9999, + title="Year released", + ) + rating: float | None = Field( # type: ignore[assignment] + None, + ge=0.0, + le=10.0, title="Movie rating", ) @@ -108,6 +141,7 @@ class MoviePartialUpdate(BaseModel): le=10.0, title="Movie rating", ) + original_link: AnyHttpUrl | None = None class MovieRead(MovieBase): diff --git a/movie_catalog/services/__init__.py b/movie_catalog/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/movie_catalog/services/auth/__init__.py b/movie_catalog/services/auth/__init__.py new file mode 100644 index 0000000..f845237 --- /dev/null +++ b/movie_catalog/services/auth/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["redis_tokens", "redis_users"] +from movie_catalog.services.auth.redis_tokens_helper import redis_tokens +from movie_catalog.services.auth.redis_users_helper import redis_users diff --git a/movie_catalog/api/api_v1/auth/services/redis_tokens_helper.py b/movie_catalog/services/auth/redis_tokens_helper.py similarity index 91% rename from movie_catalog/api/api_v1/auth/services/redis_tokens_helper.py rename to movie_catalog/services/auth/redis_tokens_helper.py index 9d014eb..3c963e3 100644 --- a/movie_catalog/api/api_v1/auth/services/redis_tokens_helper.py +++ b/movie_catalog/services/auth/redis_tokens_helper.py @@ -2,8 +2,8 @@ from redis import Redis -from movie_catalog.api.api_v1.auth.services.tokens_helper import AbstractTokensHelper from movie_catalog.config import settings +from movie_catalog.services.auth.tokens_helper import AbstractTokensHelper class RedisTokensHelper(AbstractTokensHelper): diff --git a/movie_catalog/api/api_v1/auth/services/redis_users_helper.py b/movie_catalog/services/auth/redis_users_helper.py similarity index 100% rename from movie_catalog/api/api_v1/auth/services/redis_users_helper.py rename to movie_catalog/services/auth/redis_users_helper.py diff --git a/movie_catalog/api/api_v1/auth/services/tokens_helper.py b/movie_catalog/services/auth/tokens_helper.py similarity index 100% rename from movie_catalog/api/api_v1/auth/services/tokens_helper.py rename to movie_catalog/services/auth/tokens_helper.py diff --git a/movie_catalog/api/api_v1/auth/services/users_helper.py b/movie_catalog/services/auth/users_helper.py similarity index 100% rename from movie_catalog/api/api_v1/auth/services/users_helper.py rename to movie_catalog/services/auth/users_helper.py diff --git a/movie_catalog/services/movie_catalog/__init__.py b/movie_catalog/services/movie_catalog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/movie_catalog/services/movie_catalog/form_response_helper.py b/movie_catalog/services/movie_catalog/form_response_helper.py new file mode 100644 index 0000000..b0da5aa --- /dev/null +++ b/movie_catalog/services/movie_catalog/form_response_helper.py @@ -0,0 +1,52 @@ +from typing import Any, Mapping + +from pydantic import BaseModel, ValidationError +from starlette import status +from starlette.requests import Request +from starlette.responses import HTMLResponse + +from movie_catalog.templating import templates + + +class FormResponseHelper: + def __init__(self, model: type[BaseModel], template_name: str) -> None: + self.model = model + self.template_name = template_name + + def render( + self, + request: Request, + *, + form_data: BaseModel | Mapping[str, Any] | None = None, + errors: dict[str, str] | None = None, + pydantic_error: ValidationError | None = None, + form_validated: bool = False, + **context_update: Any, # noqa: ANN401 + ) -> HTMLResponse: + context: dict[str, Any] = {} + model_schema = self.model.model_json_schema() + + if pydantic_error: + errors = self.format_pydantic_errors(pydantic_error) + + context.update( + model_schema=model_schema, + form_data=form_data, + errors=errors, + form_validated=form_validated, + ) + context.update(context_update) + return templates.TemplateResponse( + request=request, + name=self.template_name, + context=context, + status_code=( + status.HTTP_422_UNPROCESSABLE_ENTITY + if form_validated and errors + else status.HTTP_200_OK + ), + ) + + @classmethod + def format_pydantic_errors(cls, error: ValidationError) -> dict[str, str]: + return {f"{err["loc"][0]}": err["msg"] for err in error.errors()} diff --git a/movie_catalog/storage/__init__.py b/movie_catalog/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/movie_catalog/storage/movie_catalog/__init__.py b/movie_catalog/storage/movie_catalog/__init__.py new file mode 100644 index 0000000..113a4fd --- /dev/null +++ b/movie_catalog/storage/movie_catalog/__init__.py @@ -0,0 +1 @@ +from .crud import MovieCatalogStorage as MovieCatalogStorage diff --git a/movie_catalog/api/api_v1/movie_catalog/crud.py b/movie_catalog/storage/movie_catalog/crud.py similarity index 84% rename from movie_catalog/api/api_v1/movie_catalog/crud.py rename to movie_catalog/storage/movie_catalog/crud.py index a06e00a..6039752 100644 --- a/movie_catalog/api/api_v1/movie_catalog/crud.py +++ b/movie_catalog/storage/movie_catalog/crud.py @@ -5,6 +5,7 @@ from redis import Redis from movie_catalog.config import settings +from movie_catalog.exceptions import MovieAlreadyExists from movie_catalog.schemas.movie_catalog import ( Movie, MovieCreate, @@ -22,14 +23,6 @@ ) -class MovieCatalogBaseError(Exception): - pass - - -class MovieCatalogAlreadyExists(MovieCatalogBaseError): - pass - - class MovieCatalogStorage(BaseModel): hast_name: str @@ -60,7 +53,7 @@ def create(self, create_movie: MovieCreate) -> Movie: **create_movie.model_dump(), ) self.save_data(movie) - logger.debug("Add movie <%s> to catalog.", create_movie.slug) + logger.info("Add movie <%s> to catalog.", create_movie.slug) return movie def create_or_rise_if_exists(self, create_movie: MovieCreate) -> Movie: @@ -68,11 +61,11 @@ def create_or_rise_if_exists(self, create_movie: MovieCreate) -> Movie: return self.create(create_movie) logger.error("Movie with slug <%s> already exists.", create_movie.slug) - raise MovieCatalogAlreadyExists(create_movie.slug) + raise MovieAlreadyExists(create_movie.slug) def delete_by_slug(self, slug: str) -> None: redis.hdel(self.hast_name, slug) - logger.debug("Remove movie <%s> from catalog.", slug) + logger.info("Remove movie <%s> from catalog.", slug) def delete(self, movie: Movie) -> None: self.delete_by_slug(movie.slug) @@ -81,14 +74,14 @@ def update(self, movie: Movie, updated_movie: MovieUpdate) -> Movie: for field, value in updated_movie: setattr(movie, field, value) self.save_data(movie) - logger.debug("Update movie <%s>.", movie.slug) + logger.info("Update movie <%s>.", movie.slug) return movie def partial_update(self, movie: Movie, updated_movie: MoviePartialUpdate) -> Movie: for field, value in updated_movie.model_dump(exclude_unset=True).items(): setattr(movie, field, value) self.save_data(movie) - logger.debug("Partial update movie <%s>.", movie.slug) + logger.info("Partial update movie <%s>.", movie.slug) return movie diff --git a/movie_catalog/templates/about.html b/movie_catalog/templates/about.html new file mode 100644 index 0000000..1e3652d --- /dev/null +++ b/movie_catalog/templates/about.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} + +{% block title %} + About - {{ super() }} +{% endblock %} + +{% block main %} +
+ FastAPI Movie Catalog is a learning project built to demonstrate the + capabilities of the FastAPI framework. It allows you to manage a movie collection: + add new titles, store ratings, and organize your own catalog. +
++ The project includes both a backend API powered by FastAPI and a web interface based on + Jinja2 templates and Bootstrap 5. This makes the service convenient for developers, who can interact + with the API directly, as well as for end users who prefer a simple web UI. +
++ The main focus of the project is to provide a clear and extendable architecture: + developers can use it as a foundation to build more complex applications, + experiment with REST APIs, or integrate additional features such as authentication, + search, and recommendations. +
+ + ++ With its clean codebase and modern stack, FastAPI Movie Catalog is + both a practical tool and a great entry point for those who want to learn how to + design, document, and deploy APIs in Python. +
+ ++ A service for storing and rating movies. Built with FastAPI and powered by OpenAPI. +
+{{ feature }}
+| Title | +Description | +Year | +Rating | +Original Link | +Action | +
|---|---|---|---|---|---|
| + {{ movie.title }} + | + + ++ {{ movie.description[:100] ~ ('...' if movie.description|length > 100) }} + | + + ++ {{ movie.year_released }} + | + + ++ {% if movie.rating >= 8 %} + {{ movie.rating }} + {% elif movie.rating >= 5 %} + {{ movie.rating }} + {% else %} + {{ movie.rating }} + {% endif %} + | + + ++ {% if movie.original_link %} + + Open + + {% else %} + N/A + {% endif %} + | + +
+
+
+ Edit
+
+
+
+ {% include "movie-catalog/components/delete_modal_window.html" %}
+ |
+