Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions backend/backlog_app/_helpers/slug_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from slugify import slugify
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from backlog_app.models import Movie


async def generate_unique_slug(db: AsyncSession, title: str) -> str:
base_slug = slugify(title)

slug = base_slug
counter = 1

while True:
query = select(Movie).where(Movie.slug == slug)
result = await db.execute(query)

if result.scalar_one_or_none() is None:
return slug

slug = f"{base_slug}-{counter}"
counter += 1
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,9 @@

def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("movies", sa.Column("note", sa.String(length=50), nullable=True))
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("movies", "note")
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""add slug to movie table

Revision ID: 4dc9128d633a
Revises: d6cf1a127f6c
Create Date: 2026-06-05 21:56:00.239241

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "4dc9128d633a"
down_revision: Union[str, Sequence[str], None] = "d6cf1a127f6c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
op.add_column("movies", sa.Column("slug", sa.String(length=255), nullable=True))
op.create_index(op.f("ix_movies_slug"), "movies", ["slug"], unique=True)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f("ix_movies_slug"), table_name="movies")
op.drop_column("movies", "slug")
24 changes: 13 additions & 11 deletions backend/backlog_app/api/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload

from backlog_app._helpers.slug_helper import generate_unique_slug
from backlog_app.models import User
from backlog_app.models.movie import Movie
from backlog_app.schemas.movie import MovieCreate, MovieList, MovieRead, MovieUpdate
Expand All @@ -17,7 +18,8 @@
async def create_movie(
db: AsyncSession, movie_in: MovieCreate, user: User
) -> MovieRead:
movie = Movie(**movie_in.model_dump(), user_id=user.id)
slug = await generate_unique_slug(db=db, title=movie_in.title)
movie = Movie(**movie_in.model_dump(), slug=slug, user_id=user.id)
db.add(movie)
await db.commit()
await db.refresh(movie)
Expand Down Expand Up @@ -73,10 +75,10 @@ async def get_random_movie_pool(
return list(pool)


async def get_movie_by_id(
db: AsyncSession, movie_id: int, user_id: UUID | None = None
async def get_movie_by_slug(
db: AsyncSession, slug: str, user_id: UUID | None = None
) -> MovieRead | None:
query = select(Movie).options(selectinload(Movie.user)).where(Movie.id == movie_id)
query = select(Movie).options(selectinload(Movie.user)).where(Movie.slug == slug)

if user_id is not None:
query = query.where(Movie.user_id == user_id)
Expand All @@ -94,12 +96,12 @@ async def get_movie_by_id(

async def update_movie(
db: AsyncSession,
movie_id: int,
slug: str,
movie_in: MovieUpdate,
user: User,
) -> MovieRead:
result = await db.execute(
select(Movie).options(selectinload(Movie.user)).where(Movie.id == movie_id)
select(Movie).options(selectinload(Movie.user)).where(Movie.slug == slug)
)
movie = result.scalars().first()

Expand All @@ -121,11 +123,11 @@ async def update_movie(

async def partial_update_movie(
db: AsyncSession,
movie_id: int,
slug: str,
movie_in: MovieUpdate,
user: User,
) -> Movie | None:
result = await db.execute(select(Movie).where(Movie.id == movie_id))
result = await db.execute(select(Movie).where(Movie.slug == slug))
movie = result.scalars().first()
if not movie:
raise HTTPException(status_code=404)
Expand All @@ -142,10 +144,10 @@ async def partial_update_movie(

async def delete_movie(
db: AsyncSession,
movie_id: int,
slug: str,
user: User,
) -> MovieRead:
result = await db.execute(select(Movie).where(Movie.id == movie_id))
result = await db.execute(select(Movie).where(Movie.slug == slug))
movie = result.scalars().first()
if not movie:
raise HTTPException(status_code=404)
Expand All @@ -155,7 +157,7 @@ async def delete_movie(
await db.delete(movie)
await db.commit()

logger.info("Movie <%s> has been deleted.", movie_id)
logger.info("Movie <%s> has been deleted.", slug)

return MovieRead.model_validate(movie)

Expand Down
26 changes: 13 additions & 13 deletions backend/backlog_app/api/view/movie_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,45 +59,45 @@ async def get_random_movie(
return chosen


@router.get("/{movie_id}", response_model=MovieRead)
async def get_movie_by_id(
movie_id: int,
@router.get("/{slug}", response_model=MovieRead)
async def get_movie_by_slug(
slug: str,
db: Annotated[AsyncSession, Depends(get_async_session)],
user: Annotated[User, Depends(current_active_user)],
only_mine: bool = False,
):
user_id = user.id if only_mine else None
one_movie = await crud.get_movie_by_id(db, movie_id, user_id)
one_movie = await crud.get_movie_by_slug(db, slug, user_id)
return one_movie


@router.put("/{movie_id}", response_model=MovieRead)
@router.put("/{slug}", response_model=MovieRead)
async def update_movie(
movie_id: int,
slug: str,
movie_update: MovieUpdate,
db: Annotated[AsyncSession, Depends(get_async_session)],
user: Annotated[User, Depends(current_active_user)],
):
return await crud.update_movie(db, movie_id, movie_update, user)
return await crud.update_movie(db, slug, movie_update, user)


@router.patch("/{movie_id}", response_model=MovieRead)
@router.patch("/{slug}", response_model=MovieRead)
async def partial_update_movie(
movie_id: int,
slug: str,
movie_update: MovieUpdate,
db: Annotated[AsyncSession, Depends(get_async_session)],
user: Annotated[User, Depends(current_active_user)],
):
return await crud.partial_update_movie(db, movie_id, movie_update, user)
return await crud.partial_update_movie(db, slug, movie_update, user)


@router.delete(
"/{movie_id}",
"/{slug}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_movie(
movie_id: int,
slug: str,
db: Annotated[AsyncSession, Depends(get_async_session)],
user: Annotated[User, Depends(current_active_user)],
):
return await crud.delete_movie(db, movie_id, user)
return await crud.delete_movie(db, slug, user)
10 changes: 10 additions & 0 deletions backend/backlog_app/models/movie.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ class Movie(Base):

id: Mapped[int] = mapped_column(primary_key=True)

# todo: подумать на тему составного уникального индекса
# если фильмы принадлежат разным пользователям, то глобальный unique=True может оказаться лишним.
slug: Mapped[str | None] = mapped_column(
String(255),
unique=True,
nullable=True,
default="",
index=True,
)

title: Mapped[str] = mapped_column(
String(255),
nullable=False,
Expand Down
2 changes: 1 addition & 1 deletion backend/backlog_app/schemas/movie.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class MovieUpdate(MovieBase):


class MovieRead(MovieBase):
id: int
slug: str
user: UserRead
description: Annotated[str, Len(min_length=20, max_length=1000)] | None
note: Annotated[str, Len(min_length=2, max_length=50)] | None = None
Expand Down
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"fastapi[standard]>=0.128.0",
"jinja2>=3.1.6",
"pydantic-settings[yaml]>=2.12.0",
"python-slugify>=8.0.4",
"sqlalchemy[asyncio]>=2.0.46",
"taskiq>=0.12.1",
"taskiq-aio-pika>=0.5.0",
Expand Down
27 changes: 16 additions & 11 deletions backend/tests/test_schemas/test_movie.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import pytest
from pydantic import ValidationError
from slugify import slugify

from backlog_app.schemas.movie import MovieCreate, MovieRead, MovieUpdate
from backlog_app.schemas.user import UserRead
Expand All @@ -18,7 +19,7 @@ def test_movie_can_be_create_from_create_schema() -> None:
user = UserRead(id=uuid4(), email="test@example.com")
movie = MovieRead(
**movie_in.model_dump(),
id=0,
slug="test-movie",
watched=False,
created_at=datetime.now(),
user=user,
Expand All @@ -43,7 +44,7 @@ def test_movie_full_shema():
user = UserRead(id=uuid4(), email="test@example.com")
movie = MovieRead(
**movie_in.model_dump(),
id=0,
slug="test-movie",
watched=False,
created_at=datetime.now(),
user=user,
Expand All @@ -57,7 +58,7 @@ def test_movie_full_shema():

def test_movie_read_fields_contract():
expected_fields = {
"id",
"slug",
"title",
"description",
"note",
Expand All @@ -76,16 +77,20 @@ def test_movie_read_fields_contract():


@pytest.mark.parametrize(
("title", "description", "should_raise"),
("title", "slug", "description", "should_raise"),
[
pytest.param("a", "a" * 18, True, id="values-less-than-min"),
pytest.param("a" * 3, "a" * 20, False, id="minimum-values"),
pytest.param("a" * 255, "a" * 1000, False, id="maximum-values"),
pytest.param("a" * 300, "a" * 1500, True, id="values-higher-than-max"),
pytest.param("a", slugify("a"), "a" * 18, True, id="values-less-than-min"),
pytest.param("a" * 3, slugify("a" * 3), "a" * 20, False, id="minimum-values"),
pytest.param(
"a" * 255, slugify("a" * 255), "a" * 1000, False, id="maximum-values"
),
pytest.param(
"a" * 300, slugify("a" * 300), "a" * 1500, True, id="values-higher-than-max"
),
],
)
def test_movie_create_max_value(
title: str, description: str, should_raise: bool
title: str, description: str, should_raise: bool, slug: str
) -> None:
if should_raise:
with pytest.raises(ValidationError):
Expand All @@ -101,7 +106,7 @@ def test_movie_create_max_value(
user = UserRead(id=uuid4(), email="test@example.com")
movie = MovieRead(
**movie_in.model_dump(),
id=0,
slug=slug,
watched=False,
created_at=datetime.now(),
user=user,
Expand All @@ -115,7 +120,7 @@ def test_movie_update_from_update_schema() -> None:
user = UserRead(id=uuid4(), email="test@example.com")

movie = MovieRead(
id=0,
slug="test-movie",
user=user,
title="Test Movie",
description="Test Movie Description",
Expand Down
25 changes: 24 additions & 1 deletion backend/uv.lock

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

Loading
Loading