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 %} +
+
+
+

About the Project

+

+ 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. +

+ + +

Key Features

+
    +
  • 🎬 CRUD operations for movies (create, read, update, delete)
  • +
  • ⭐ User-friendly movie rating system
  • +
  • 📑 Automatic API documentation via Swagger and ReDoc
  • +
  • 🖥️ Responsive Bootstrap 5 interface
  • +
  • ⚡ High performance thanks to FastAPI’s async architecture
  • +
  • 🛠️ Easy to extend with new modules (e.g., authentication, search, + 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. +

+ +

Social

+ + +
+
+
+{% endblock %} diff --git a/movie_catalog/templates/base.html b/movie_catalog/templates/base.html new file mode 100644 index 0000000..e94bc68 --- /dev/null +++ b/movie_catalog/templates/base.html @@ -0,0 +1,42 @@ + + + + + + {% block title %}Movie{% endblock %} + + + + + +{% include 'components/develop_message.html' %} +
+ {% include "components/navbar.html" %} +
+ +
+ {% block main %} + {% endblock %} +
+ + + + + + + + diff --git a/movie_catalog/templates/components/develop_message.html b/movie_catalog/templates/components/develop_message.html new file mode 100644 index 0000000..268e849 --- /dev/null +++ b/movie_catalog/templates/components/develop_message.html @@ -0,0 +1,3 @@ +
+ The service is still under development. +
diff --git a/movie_catalog/templates/components/navbar.html b/movie_catalog/templates/components/navbar.html new file mode 100644 index 0000000..4ab0eae --- /dev/null +++ b/movie_catalog/templates/components/navbar.html @@ -0,0 +1,33 @@ +{% set navigation = [ + ("home", "Home"), + ("about", "About"), + ("movie-catalog:list", "Movies Rating"), +] %} + + diff --git a/movie_catalog/templates/home.html b/movie_catalog/templates/home.html new file mode 100644 index 0000000..0c6059a --- /dev/null +++ b/movie_catalog/templates/home.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %} + Home - {{ super() }} +{% endblock %} + +{% block main %} +
+
+

🎬 Great Movies Catalog

+

+ A service for storing and rating movies. Built with FastAPI and powered by OpenAPI. +

+ +
+ +
+ {% for feature_name, feature in features.items() %} +
+
+

{{ feature_name }}

+

{{ feature }}

+
+
+ {% endfor %} +
+
+ +{% endblock %} diff --git a/movie_catalog/templates/macros/form-fields.html b/movie_catalog/templates/macros/form-fields.html new file mode 100644 index 0000000..768a927 --- /dev/null +++ b/movie_catalog/templates/macros/form-fields.html @@ -0,0 +1,70 @@ +{% macro form_field(field_name, field_schema, is_required, validated=False, error=None, value=None) %} + {% set input_type = "text" %} + {% if "rating" in field_name %} + {% set input_type = "number" %} + {% set min_value = 0 %} + {% set max_value = 10 %} + {% set step_value = 0.1 %} + {% elif "year" in field_name %} + {% set input_type = "number" %} + {% set min_value = 1900 %} + {% set max_value = 2100 %} + {% set step_value = 1 %} + {% endif %} + + + {% set icon_class = "" %} + {% if "title" in field_name %} + {% set icon_class = "bi bi-film" %} + {% elif "description" in field_name %} + {% set icon_class = "bi bi-card-text" %} + {% elif "year" in field_name %} + {% set icon_class = "bi bi-calendar3" %} + {% elif "rating" in field_name %} + {% set icon_class = "bi bi-bar-chart-line" %} + {% elif "link" in field_name %} + {% set icon_class = "bi bi-link-45deg" %} + {% elif "slug" in field_name %} + {% set icon_class = "bi bi-tag" %} + {% endif %} + + {% if "description" in field_name %} +
+ + + {% if validated and error %} +
{{ error }}
+ {% endif %} +
+ {% else %} +
+ + + {% if validated and error %} +
{{ error }}
+ {% endif %} +
+ {% endif %} +{% endmacro %} diff --git a/movie_catalog/templates/movie-catalog/components/delete_modal_window.html b/movie_catalog/templates/movie-catalog/components/delete_modal_window.html new file mode 100644 index 0000000..4df9bba --- /dev/null +++ b/movie_catalog/templates/movie-catalog/components/delete_modal_window.html @@ -0,0 +1,30 @@ + diff --git a/movie_catalog/templates/movie-catalog/create.html b/movie_catalog/templates/movie-catalog/create.html new file mode 100644 index 0000000..5a29a24 --- /dev/null +++ b/movie_catalog/templates/movie-catalog/create.html @@ -0,0 +1,29 @@ +{% from "movie-catalog/macros/form.html" import render_form with context %} +{% extends "base.html" %} +{% block title %}Add new Movie{% endblock %} + +{% block main %} +
+
+
+
+
+

+ Add new movie +

+
+ + {{ render_form( + action=url_for('movie-catalog:create'), + button_text="Create" + ) }} + + +
+
+
+
+{% endblock %} diff --git a/movie_catalog/templates/movie-catalog/list.html b/movie_catalog/templates/movie-catalog/list.html new file mode 100644 index 0000000..dc42b9f --- /dev/null +++ b/movie_catalog/templates/movie-catalog/list.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% block title %} + Movies Rating +{% endblock %} + +{% block main %} +
+

+ Movies Rating +

+ + {% for msg in get_flashed_messages(request) %} +
+ {{ msg.message }} +
+ {% endfor %} + + {% if movie_catalog %} +
+
+
+ + + + + + + + + + + + + {% for movie in movie_catalog %} + + + + + + + + + + + + + + + + + + + {% endfor %} + +
TitleDescriptionYearRatingOriginal LinkAction
+ {{ 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" %} +
+
+
+
+
+ Add movie to Rating +
+ {% else %} + + {% endif %} +
+ + + +{% endblock %} diff --git a/movie_catalog/templates/movie-catalog/macros/form.html b/movie_catalog/templates/movie-catalog/macros/form.html new file mode 100644 index 0000000..71746a0 --- /dev/null +++ b/movie_catalog/templates/movie-catalog/macros/form.html @@ -0,0 +1,30 @@ +{% from "macros/form-fields.html" import form_field %} +{% macro render_form( +action, +button_text) %} + +
+
+ {% for field_name, field_schema in model_schema.properties.items() %} + {% set is_required = field_name in model_schema.get("required") or [] %} + {{ form_field( + field_name=field_name, + field_schema=field_schema, + is_required=is_required, + validated=form_validated, + error=errors and errors[field_name] or None, + value=form_data and form_data[field_name] or field_schema.default + ) }} + {% endfor %} + +
+ + + Back to Rating list + +
+
+
+{% endmacro %} diff --git a/movie_catalog/templates/movie-catalog/update.html b/movie_catalog/templates/movie-catalog/update.html new file mode 100644 index 0000000..8f49424 --- /dev/null +++ b/movie_catalog/templates/movie-catalog/update.html @@ -0,0 +1,29 @@ +{% from "movie-catalog/macros/form.html" import render_form with context %} +{% extends "base.html" %} +{% block title %}Update {{ movie.title }}{% endblock %} + +{% block main %} +
+
+
+
+
+

+ Update {{ movie.title }} movie +

+
+ + {{ render_form( + action=url_for("movie-catalog:update", slug=movie.slug), + button_text="Update" + ) }} + + +
+
+
+
+{% endblock %} diff --git a/movie_catalog/templating/__init__.py b/movie_catalog/templating/__init__.py new file mode 100644 index 0000000..5274155 --- /dev/null +++ b/movie_catalog/templating/__init__.py @@ -0,0 +1 @@ +from .jinja_templates import templates as templates diff --git a/movie_catalog/templating/jinja_templates.py b/movie_catalog/templating/jinja_templates.py new file mode 100644 index 0000000..cf7b0a0 --- /dev/null +++ b/movie_catalog/templating/jinja_templates.py @@ -0,0 +1,23 @@ +from datetime import date + +from fastapi import Request +from fastapi.templating import Jinja2Templates + +from movie_catalog.config import BASE_DIR +from movie_catalog.misc.flash_messages import get_flashed_messages + + +def inject_current_date( + request: Request, # noqa: ARG001 Unused function argument: `request` +) -> dict[str, date]: + return {"today": date.today()} + + +templates = Jinja2Templates( + directory=BASE_DIR / "templates", + context_processors=[ + inject_current_date, + ], +) + +templates.env.globals["get_flashed_messages"] = get_flashed_messages diff --git a/movie_catalog/tests/conftest.py b/movie_catalog/tests/conftest.py index 7f56180..24912f5 100644 --- a/movie_catalog/tests/conftest.py +++ b/movie_catalog/tests/conftest.py @@ -6,8 +6,8 @@ import pytest -from movie_catalog.api.api_v1.movie_catalog.crud import storage from movie_catalog.schemas.movie_catalog import Movie, MovieCreate +from movie_catalog.storage.movie_catalog.crud import storage @pytest.fixture(scope="session", autouse=True) diff --git a/movie_catalog/tests/test_api/conftest.py b/movie_catalog/tests/test_api/conftest.py index 42cff1a..df37a09 100644 --- a/movie_catalog/tests/test_api/conftest.py +++ b/movie_catalog/tests/test_api/conftest.py @@ -3,8 +3,8 @@ import pytest from fastapi.testclient import TestClient -from movie_catalog.api.api_v1.auth.services import redis_tokens from movie_catalog.main import app +from movie_catalog.services.auth import redis_tokens @pytest.fixture() diff --git a/movie_catalog/tests/test_api/test_api_v1/test_auth/test_services/test_redis_tokens_helper.py b/movie_catalog/tests/test_api/test_api_v1/test_auth/test_services/test_redis_tokens_helper.py index 01c5baf..992ff8a 100644 --- a/movie_catalog/tests/test_api/test_api_v1/test_auth/test_services/test_redis_tokens_helper.py +++ b/movie_catalog/tests/test_api/test_api_v1/test_auth/test_services/test_redis_tokens_helper.py @@ -1,6 +1,6 @@ from unittest import TestCase -from movie_catalog.api.api_v1.auth.services import redis_tokens +from movie_catalog.services.auth import redis_tokens class RedisTokensHelperTestCase(TestCase): diff --git a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_crud.py b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_crud.py index 533f7c8..3ac28e6 100644 --- a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_crud.py +++ b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_crud.py @@ -4,16 +4,16 @@ import pytest -from movie_catalog.api.api_v1.movie_catalog.crud import ( - MovieCatalogAlreadyExists, - storage, -) +from movie_catalog.exceptions import MovieAlreadyExists from movie_catalog.schemas.movie_catalog import ( Movie, MovieCreate, MoviePartialUpdate, MovieUpdate, ) +from movie_catalog.storage.movie_catalog.crud import ( + storage, +) from movie_catalog.tests.conftest import build_movie_create_random_slug @@ -99,7 +99,7 @@ def tearDownClass(cls) -> None: def test_create_or_raise_if_exists(movie: Movie) -> None: movie_create = MovieCreate(**movie.model_dump()) - with pytest.raises(MovieCatalogAlreadyExists, match=movie_create.slug) as exc_info: + with pytest.raises(MovieAlreadyExists, match=movie_create.slug) as exc_info: storage.create_or_rise_if_exists(movie_create) assert exc_info.value.args[0] == movie_create.slug @@ -108,7 +108,7 @@ def test_create_or_raise_if_exists(movie: Movie) -> None: def test_create_twice() -> None: movie_create = build_movie_create_random_slug() storage.create_or_rise_if_exists(movie_create) - with pytest.raises(MovieCatalogAlreadyExists, match=movie_create.slug) as exc_info: + with pytest.raises(MovieAlreadyExists, match=movie_create.slug) as exc_info: storage.create_or_rise_if_exists(movie_create) assert exc_info.value.args == (movie_create.slug,) diff --git a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_dependencies.py b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_dependencies.py index 6bbfb72..caa648b 100644 --- a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_dependencies.py +++ b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_dependencies.py @@ -1,4 +1,4 @@ -from movie_catalog.api.api_v1.movie_catalog.dependencies import UNSAFE_METHOD +from movie_catalog.dependencies.auth import UNSAFE_METHOD class TestUnsafeMethods: diff --git a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_details_veiew.py b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_details_veiew.py index 6391f31..54b0255 100644 --- a/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_details_veiew.py +++ b/movie_catalog/tests/test_api/test_api_v1/test_movie_catalog/test_views/test_details_veiew.py @@ -5,7 +5,6 @@ from fastapi import status from fastapi.testclient import TestClient -from movie_catalog.api.api_v1.movie_catalog.crud import storage from movie_catalog.main import app from movie_catalog.schemas.movie_catalog import ( DESCRIPTION_MAX_LENGTH, @@ -13,6 +12,7 @@ Movie, MovieUpdate, ) +from movie_catalog.storage.movie_catalog.crud import storage from movie_catalog.tests.conftest import create_movie, create_movie_random_slug pytestmark = pytest.mark.apitest @@ -77,7 +77,7 @@ def test_update_movie_details_partial( assert response.status_code == status.HTTP_200_OK, response.text movie_from_db = storage.get_by_slug(movie.slug) assert movie_from_db != movie_before_update - assert movie_from_db.description == new_description # type: ignore + assert movie_from_db.description == new_description # type: ignore[union-attr] class TestUpdate: @@ -135,5 +135,5 @@ def test_update_movie_details( assert response.status_code == status.HTTP_200_OK, response.text movie_from_db = storage.get_by_slug(movie.slug) assert movie_from_db != movie_before_update - assert movie_from_db.description == new_description # type: ignore - assert movie_from_db.title == new_title # type: ignore + assert movie_from_db.description == new_description # type: ignore[union-attr] + assert movie_from_db.title == new_title # type: ignore[union-attr] diff --git a/movie_catalog/tests/test_api/test_views.py b/movie_catalog/tests/test_api/test_views.py index c794201..8c113f3 100644 --- a/movie_catalog/tests/test_api/test_views.py +++ b/movie_catalog/tests/test_api/test_views.py @@ -1,12 +1,20 @@ import pytest +from fastapi import status from fastapi.testclient import TestClient -@pytest.mark.apitest -def test_root_view(client: TestClient) -> None: +@pytest.mark.templatetest +def test_root(client: TestClient) -> None: response = client.get("/") - assert response.status_code == 200 - assert response.json() == { - "message": "Hello World", - "docs_url": "http://testserver/docs", - } + assert response.status_code == status.HTTP_200_OK, response.text + assert response.template.name == "home.html" # type: ignore[attr-defined] + assert "features" in response.context, response.context # type: ignore[attr-defined] + assert isinstance(response.context["features"], dict) # type: ignore[attr-defined] + + +@pytest.mark.templatetest +def test_about(client: TestClient) -> None: + response = client.get("/about") + assert response.status_code == status.HTTP_200_OK + assert response.template.name == "about.html" # type: ignore[attr-defined] + assert "today" in response.context, response.context # type: ignore[attr-defined] diff --git a/pyproject.toml b/pyproject.toml index 8f1a0ba..7084611 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,10 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "fastapi[standard]>=0.115.12", + "itsdangerous>=2.2.0", "pydantic-settings[yaml]>=2.10.1", "redis[hiredis]>=6.0.0", + "ruff>=0.11.12", "typer>=0.15.2", "types-redis>=4.6.0.20241004", ] @@ -30,7 +32,8 @@ required-version = "~=0.8.2" minversion = "8.3" addopts = ["-ra", "--strict-markers"] markers = [ - "apitest: test any api call" + "apitest: test any api call", + "templatetest: test any http HTML template page" ] [tool.mypy] diff --git a/uv.lock b/uv.lock index 7cc3d71..545591f 100644 --- a/uv.lock +++ b/uv.lock @@ -254,8 +254,10 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "fastapi", extra = ["standard"] }, + { name = "itsdangerous" }, { name = "pydantic-settings", extra = ["yaml"] }, { name = "redis", extra = ["hiredis"] }, + { name = "ruff" }, { name = "typer" }, { name = "types-redis" }, ] @@ -273,8 +275,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.10.1" }, { name = "redis", extras = ["hiredis"], specifier = ">=6.0.0" }, + { name = "ruff", specifier = ">=0.11.12" }, { name = "typer", specifier = ">=0.15.2" }, { name = "types-redis", specifier = ">=4.6.0.20241004" }, ] @@ -422,6 +426,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6"