Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
20f7ac5
add jinja template var
Nottezz Sep 27, 2025
8c02fef
add base template
Nottezz Sep 27, 2025
1723cdd
add context inject
Nottezz Sep 27, 2025
3c58805
add home page
Nottezz Sep 27, 2025
62458ab
add about project page
Nottezz Sep 27, 2025
a8db479
add to footer link for feedback
Nottezz Sep 27, 2025
59800f5
add features as context
Nottezz Sep 27, 2025
c53c49b
add navbar with active links
Nottezz Sep 27, 2025
6b52e46
add new pytest marker
Nottezz Oct 5, 2025
212b286
editing the test to get an HTML response
Nottezz Oct 5, 2025
6f3b742
move storage to independent module
Nottezz Oct 5, 2025
b7e2595
move exceptions.py to a separate module
Nottezz Oct 5, 2025
4e4ec7c
optimize import
Nottezz Oct 5, 2025
82285ac
add to global state var for storage
Nottezz Oct 5, 2025
d4a8622
move HTML view to rest module
Nottezz Oct 5, 2025
7477de4
add new view: list movie rating
Nottezz Oct 5, 2025
9adc680
include new page to nav bar
Nottezz Oct 5, 2025
160b886
edit path at bat script
Nottezz Oct 5, 2025
1882168
new field: original link
Nottezz Oct 5, 2025
9b9b368
add create view with full form validation
Nottezz Oct 5, 2025
14b660b
button for open add form
Nottezz Oct 5, 2025
0c65cb8
move auth to separate module
Nottezz Oct 5, 2025
986fd6b
add global dep for auth
Nottezz Oct 5, 2025
81da1b9
optional field
Nottezz Oct 5, 2025
f7773cf
ignore or fix mypy errors
Nottezz Oct 5, 2025
37404ba
refactor create view: add form response helper
Nottezz Oct 7, 2025
e5547e8
fix mypy errors
Nottezz Oct 7, 2025
2f40dba
edit import
Nottezz Oct 7, 2025
f96fb7b
refactor creating form
Nottezz Oct 13, 2025
93d31d3
add update view
Nottezz Oct 13, 2025
0e9ec9a
minor changes
Nottezz Oct 13, 2025
9723f57
fill form with current short url data
Nottezz Oct 13, 2025
f873198
handle short url update validation errors
Nottezz Oct 13, 2025
e46b6ea
modal window for deleting
Nottezz Oct 13, 2025
9e752d0
include new router
Nottezz Oct 13, 2025
85c1c61
delete movie
Nottezz Oct 14, 2025
48457cb
add HTMX to delete short url
Nottezz Oct 14, 2025
a1228ab
add session config
Nottezz Oct 14, 2025
5894a89
install new dep - itsdangerous
Nottezz Oct 14, 2025
9af2b74
add middleware - Session
Nottezz Oct 14, 2025
5c8caaa
add middleware - Session
Nottezz Oct 14, 2025
75f42b1
use session to show message
Nottezz Oct 14, 2025
1f49db4
create flash messages utils
Nottezz Oct 14, 2025
73e1f61
set get_flashed_messages as global context var
Nottezz Oct 14, 2025
380ce67
add flash messages
Nottezz Oct 14, 2025
07a8450
fix imports
Nottezz Oct 14, 2025
73a5c72
mypy fix errors
Nottezz Oct 14, 2025
72fe57b
add env var session secret key for running
Nottezz Oct 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/python-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions movie_catalog/api/api_v1/auth/services/__init__.py

This file was deleted.

53 changes: 2 additions & 51 deletions movie_catalog/api/api_v1/movie_catalog/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
8 changes: 2 additions & 6 deletions movie_catalog/api/api_v1/movie_catalog/views/details_view.py
Original file line number Diff line number Diff line change
@@ -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}",
Expand Down Expand Up @@ -50,8 +48,6 @@
},
)

MovieBySlug = Annotated[Movie, Depends(prefetch_film)]


@router.get("/", response_model=MovieRead)
def get_movie(movie: MovieBySlug) -> Movie:
Expand Down
10 changes: 5 additions & 5 deletions movie_catalog/api/api_v1/movie_catalog/views/list_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
14 changes: 0 additions & 14 deletions movie_catalog/api/main_view.py

This file was deleted.

7 changes: 6 additions & 1 deletion movie_catalog/app_lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion movie_catalog/commands/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions movie_catalog/config.default.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
movie-catalog:
logging:
log_level_name: INFO
session:
secret_key:
5 changes: 5 additions & 0 deletions movie_catalog/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -103,6 +107,7 @@ def settings_customise_sources(

logging: LoggingConfig = LoggingConfig()
redis: RedisConfig = RedisConfig()
session: SessionConfig


settings = Settings()
52 changes: 52 additions & 0 deletions movie_catalog/dependencies/auth.py
Original file line number Diff line number Diff line change
@@ -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)
32 changes: 32 additions & 0 deletions movie_catalog/dependencies/movie_catalog.py
Original file line number Diff line number Diff line change
@@ -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)]
10 changes: 10 additions & 0 deletions movie_catalog/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class MovieCatalogBaseError(Exception):
"""
Base exception class for Movie Catalog
"""


class MovieAlreadyExists(MovieCatalogBaseError):
"""
Exception raised when a movie already exists
"""
9 changes: 7 additions & 2 deletions movie_catalog/main.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
2 changes: 1 addition & 1 deletion movie_catalog/manage.bat
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
@echo off
uv run --script manage.py %*
uv run --script movie_catalog/manage.py %*
Empty file added movie_catalog/misc/__init__.py
Empty file.
26 changes: 26 additions & 0 deletions movie_catalog/misc/flash_messages.py
Original file line number Diff line number Diff line change
@@ -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]
10 changes: 10 additions & 0 deletions movie_catalog/rest/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
Loading