From d01a5aac5940c01ab34d974974d16bd3e4c8cd3b Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Fri, 5 Jun 2026 22:50:14 +0300 Subject: [PATCH 1/9] add python slugify --- backend/pyproject.toml | 1 + backend/uv.lock | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9b1ad12..03bcf57 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", diff --git a/backend/uv.lock b/backend/uv.lock index f67f6cf..d08cd3c 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14'", @@ -279,6 +279,7 @@ dependencies = [ { name = "fastapi-users", extra = ["sqlalchemy"] }, { name = "jinja2" }, { name = "pydantic-settings", extra = ["yaml"] }, + { name = "python-slugify" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "taskiq" }, { name = "taskiq-aio-pika" }, @@ -307,6 +308,7 @@ requires-dist = [ { name = "fastapi-users", extras = ["sqlalchemy"], specifier = ">=15.0.3" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.12.0" }, + { name = "python-slugify", specifier = ">=8.0.4" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.46" }, { name = "taskiq", specifier = ">=0.12.1" }, { name = "taskiq-aio-pika", specifier = ">=0.5.0" }, @@ -1580,6 +1582,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, ] +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + [[package]] name = "pytokens" version = "0.4.0" @@ -1847,6 +1861,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/58/11eacc48d615703618f93ff44812409dca94cb465ab5cb173a8a90e079b1/taskiq_fastapi-0.4.0-py3-none-any.whl", hash = "sha256:9e7e57fa337b4fad2641c8761cafc7b54a63bf7bf726423a28fc2b15c55fd0bc", size = 5369, upload-time = "2025-11-29T15:23:00.185Z" }, ] +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + [[package]] name = "typer" version = "0.21.1" From 3b69d4f76aeac1f65f6a45bc774d1b174aa4f3b5 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Fri, 5 Jun 2026 22:52:00 +0300 Subject: [PATCH 2/9] add new field to model and schemas: slug --- backend/backlog_app/models/movie.py | 10 ++++++++++ backend/backlog_app/schemas/movie.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/backlog_app/models/movie.py b/backend/backlog_app/models/movie.py index b057e1a..93cc389 100644 --- a/backend/backlog_app/models/movie.py +++ b/backend/backlog_app/models/movie.py @@ -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=False, + default="", + index=True, + ) + title: Mapped[str] = mapped_column( String(255), nullable=False, diff --git a/backend/backlog_app/schemas/movie.py b/backend/backlog_app/schemas/movie.py index f5b9979..8f13d96 100644 --- a/backend/backlog_app/schemas/movie.py +++ b/backend/backlog_app/schemas/movie.py @@ -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 From eda2cd7172118f93782bb28caac4abd48afd1c9e Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Fri, 5 Jun 2026 22:52:49 +0300 Subject: [PATCH 3/9] add slug helper for generate slug from title --- backend/backlog_app/_helpers/slug_helper.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 backend/backlog_app/_helpers/slug_helper.py diff --git a/backend/backlog_app/_helpers/slug_helper.py b/backend/backlog_app/_helpers/slug_helper.py new file mode 100644 index 0000000..173ecd7 --- /dev/null +++ b/backend/backlog_app/_helpers/slug_helper.py @@ -0,0 +1,21 @@ +from models import Movie +from slugify import slugify +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +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 From d9c4fd679e97fae3557044ad70f384d79bfbabff Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Fri, 5 Jun 2026 22:53:09 +0300 Subject: [PATCH 4/9] add new migration --- ...f1a127f6c_add_field_note_to_movie_table.py | 4 --- ...56-4dc9128d633a_add_slug_to_movie_table.py | 30 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 backend/backlog_app/alembic/versions/2026_06_05_2156-4dc9128d633a_add_slug_to_movie_table.py diff --git a/backend/backlog_app/alembic/versions/2026_06_05_2016-d6cf1a127f6c_add_field_note_to_movie_table.py b/backend/backlog_app/alembic/versions/2026_06_05_2016-d6cf1a127f6c_add_field_note_to_movie_table.py index 819872b..c676cc4 100644 --- a/backend/backlog_app/alembic/versions/2026_06_05_2016-d6cf1a127f6c_add_field_note_to_movie_table.py +++ b/backend/backlog_app/alembic/versions/2026_06_05_2016-d6cf1a127f6c_add_field_note_to_movie_table.py @@ -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 ### diff --git a/backend/backlog_app/alembic/versions/2026_06_05_2156-4dc9128d633a_add_slug_to_movie_table.py b/backend/backlog_app/alembic/versions/2026_06_05_2156-4dc9128d633a_add_slug_to_movie_table.py new file mode 100644 index 0000000..a3d2433 --- /dev/null +++ b/backend/backlog_app/alembic/versions/2026_06_05_2156-4dc9128d633a_add_slug_to_movie_table.py @@ -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=False)) + 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") From ae129382d79ae00a39bcaebafff5a723041ea72f Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Fri, 5 Jun 2026 22:53:41 +0300 Subject: [PATCH 5/9] edit crud function: delete id, use slug --- backend/backlog_app/api/crud.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/backend/backlog_app/api/crud.py b/backend/backlog_app/api/crud.py index 1691b20..175c142 100644 --- a/backend/backlog_app/api/crud.py +++ b/backend/backlog_app/api/crud.py @@ -1,6 +1,7 @@ import logging from uuid import UUID +from _helpers.slug_helper import generate_unique_slug from fastapi import HTTPException from sqlalchemy import or_ from sqlalchemy.ext.asyncio import AsyncSession @@ -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) @@ -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) @@ -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() @@ -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) @@ -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) @@ -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) From 1123231d4d20741173f75d986bbff151730138f3 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Fri, 5 Jun 2026 22:54:10 +0300 Subject: [PATCH 6/9] remove movie_id param, add slug --- backend/backlog_app/api/view/movie_view.py | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/backend/backlog_app/api/view/movie_view.py b/backend/backlog_app/api/view/movie_view.py index 684f76f..691134e 100644 --- a/backend/backlog_app/api/view/movie_view.py +++ b/backend/backlog_app/api/view/movie_view.py @@ -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) From 7040bbc26006845c7d6bbf7c843e52c64ac298d7 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Fri, 5 Jun 2026 22:54:41 +0300 Subject: [PATCH 7/9] adapting the frontend to slug --- frontend/src/api/movies.ts | 31 ++++++++----------- frontend/src/components/ui/MovieCard.vue | 6 ++-- frontend/src/components/ui/MovieListView.vue | 8 ++--- frontend/src/components/ui/MovieRoulette.vue | 2 +- frontend/src/composables/useMovieRoulette.ts | 12 +++---- frontend/src/router/index.ts | 2 +- frontend/src/stores/movies.ts | 14 ++++----- frontend/src/views/movies/MovieDetailView.vue | 8 ++--- 8 files changed, 39 insertions(+), 44 deletions(-) diff --git a/frontend/src/api/movies.ts b/frontend/src/api/movies.ts index 368fff8..532744d 100644 --- a/frontend/src/api/movies.ts +++ b/frontend/src/api/movies.ts @@ -18,7 +18,7 @@ export interface MovieUpdate extends Partial { } export interface MovieRead { - id: number + slug: string title: string description: string | null note: string | null @@ -47,16 +47,16 @@ export const moviesApi = { return data }, - async getById(id: number, onlyMine = false): Promise { - const { data } = await api.get(`/movies/${id}`, { - params: { only_mine: onlyMine }, - }) - return data - }, + async getBySlug(slug: string, onlyMine = false): Promise { + const { data } = await api.get(`/movies/${slug}`, { + params: { only_mine: onlyMine }, + }) + return data +}, - async getRandom(excludeIds: number[] = []): Promise { + async getRandom(excludeSlugs: string[] = []): Promise { const params = new URLSearchParams() - excludeIds.forEach((id) => params.append('exclude_ids', String(id))) + excludeSlugs.forEach((slug) => params.append('exclude_slugs', slug)) const { data } = await api.get('/movies/random', { params }) return data }, @@ -66,17 +66,12 @@ export const moviesApi = { return data }, - async update(id: number, movie: MovieUpdate): Promise { - const { data } = await api.put(`/movies/${id}`, movie) - return data - }, - - async patch(id: number, movie: MovieUpdate): Promise { - const { data } = await api.patch(`/movies/${id}`, movie) + async patch(slug: string, movie: MovieUpdate): Promise { + const { data } = await api.patch(`/movies/${slug}`, movie) return data }, - async delete(id: number): Promise { - await api.delete(`/movies/${id}`) + async delete(slug: string): Promise { + await api.delete(`/movies/${slug}`) }, } diff --git a/frontend/src/components/ui/MovieCard.vue b/frontend/src/components/ui/MovieCard.vue index 5359b63..027f695 100644 --- a/frontend/src/components/ui/MovieCard.vue +++ b/frontend/src/components/ui/MovieCard.vue @@ -29,7 +29,7 @@
- +

@@ -117,7 +117,7 @@ @@ -157,7 +157,7 @@ diff --git a/frontend/src/components/ui/MovieListView.vue b/frontend/src/components/ui/MovieListView.vue index d0acf25..625a3c3 100644 --- a/frontend/src/components/ui/MovieListView.vue +++ b/frontend/src/components/ui/MovieListView.vue @@ -88,7 +88,7 @@
[0] & { watched?: boolean }) { try { if (editingMovie.value) { - await store.updateMovie(editingMovie.value.id, data) + await store.updateMovie(editingMovie.value.slug, data) } else { await store.addMovie(data) } @@ -229,7 +229,7 @@ function confirmDelete(movie: MovieRead) { async function handleDelete() { if (!deletingMovie.value) return try { - await store.deleteMovie(deletingMovie.value.id) + await store.deleteMovie(deletingMovie.value.slug) deletingMovie.value = null } catch (e: unknown) { const err = e as { response?: { status?: number } } diff --git a/frontend/src/components/ui/MovieRoulette.vue b/frontend/src/components/ui/MovieRoulette.vue index f1bb22e..5a127b8 100644 --- a/frontend/src/components/ui/MovieRoulette.vue +++ b/frontend/src/components/ui/MovieRoulette.vue @@ -162,7 +162,7 @@ diff --git a/frontend/src/composables/useMovieRoulette.ts b/frontend/src/composables/useMovieRoulette.ts index a8ec7fa..0d430ed 100644 --- a/frontend/src/composables/useMovieRoulette.ts +++ b/frontend/src/composables/useMovieRoulette.ts @@ -3,7 +3,7 @@ import { moviesApi, type MovieRead } from '@/api/movies.ts' export function useMovieRoulette() { const pickedMovie = ref(null) - const excludedIds = ref([]) + const excludedSlugs = ref([]) const loading = ref(false) const exhausted = ref(false) const isOpen = ref(false) @@ -13,7 +13,7 @@ export function useMovieRoulette() { exhausted.value = false try { - const movie = await moviesApi.getRandom(excludedIds.value) + const movie = await moviesApi.getRandom(excludedSlugs.value) pickedMovie.value = movie } catch (e: unknown) { const err = e as { response?: { status?: number } } @@ -29,7 +29,7 @@ export function useMovieRoulette() { // Убрать текущий фильм из пула и сразу крутить снова async function reject() { if (pickedMovie.value) { - excludedIds.value.push(pickedMovie.value.id) + excludedSlugs.value.push(pickedMovie.value.slug) pickedMovie.value = null await spin() } @@ -45,14 +45,14 @@ export function useMovieRoulette() { function close() { isOpen.value = false pickedMovie.value = null - excludedIds.value = [] + excludedSlugs.value = [] exhausted.value = false loading.value = false } // Начать заново, не закрывая модалку async function restart() { - excludedIds.value = [] + excludedSlugs.value = [] exhausted.value = false pickedMovie.value = null await spin() @@ -60,7 +60,7 @@ export function useMovieRoulette() { return { pickedMovie, - excludedIds, + excludedSlugs: excludedSlugs, loading, exhausted, isOpen, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 9ab10b5..ce9c096 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -53,7 +53,7 @@ const router = createRouter({ meta: { requiresAuth: true }, }, { - path: '/movies/:id', + path: '/movies/:slug', name: 'movie-detail', component: () => import('@/views/movies/MovieDetailView.vue'), meta: { requiresAuth: true }, diff --git a/frontend/src/stores/movies.ts b/frontend/src/stores/movies.ts index ae69e65..f60671f 100644 --- a/frontend/src/stores/movies.ts +++ b/frontend/src/stores/movies.ts @@ -26,20 +26,20 @@ export const useMoviesStore = defineStore('movies', () => { return created } - async function updateMovie(id: number, movie: MovieUpdate): Promise { - const updated = await moviesApi.patch(id, movie) - const index = movies.value.findIndex((m) => m.id === id) + async function updateMovie(slug: string, movie: MovieUpdate): Promise { + const updated = await moviesApi.patch(slug, movie) + const index = movies.value.findIndex((m) => m.slug === slug) if (index !== -1) movies.value[index] = updated return updated } - async function deleteMovie(id: number) { - await moviesApi.delete(id) - movies.value = movies.value.filter((m) => m.id !== id) + async function deleteMovie(slug: string) { + await moviesApi.delete(slug) + movies.value = movies.value.filter((m) => m.slug !== slug) } async function toggleWatched(movie: MovieRead) { - return updateMovie(movie.id, { watched: !movie.watched }) + return updateMovie(movie.slug, { watched: !movie.watched }) } return { diff --git a/frontend/src/views/movies/MovieDetailView.vue b/frontend/src/views/movies/MovieDetailView.vue index 518f6af..1bfe6b6 100644 --- a/frontend/src/views/movies/MovieDetailView.vue +++ b/frontend/src/views/movies/MovieDetailView.vue @@ -224,7 +224,7 @@ const isOwner = computed(() => onMounted(async () => { try { - movie.value = await moviesApi.getById(Number(route.params.id)) + movie.value = await moviesApi.getBySlug(String(route.params.slug)) } catch { error.value = 'Фильм не найден' } finally { @@ -256,7 +256,7 @@ async function handleEdit(data: Parameters[1]) { return } try { - movie.value = await store.updateMovie(movie.value.id, data) + movie.value = await store.updateMovie(movie.value.slug, data) showEditModal.value = false } catch (e: unknown) { const err = e as { response?: { status?: number } } @@ -274,8 +274,8 @@ async function handleDelete() { return } try { - await store.deleteMovie(movie.value.id) - router.push('/movies') + await store.deleteMovie(movie.value.slug) + await router.push('/movies') } catch (e: unknown) { const err = e as { response?: { status?: number } } confirmDelete.value = false From ae884179821902b5ea6c9a0f9c03648cf827c1c6 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Fri, 5 Jun 2026 23:11:00 +0300 Subject: [PATCH 8/9] adapting the tests to slug --- backend/backlog_app/_helpers/slug_helper.py | 3 ++- backend/backlog_app/api/crud.py | 2 +- backend/tests/test_schemas/test_movie.py | 27 ++++++++++++--------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/backend/backlog_app/_helpers/slug_helper.py b/backend/backlog_app/_helpers/slug_helper.py index 173ecd7..ad1bb52 100644 --- a/backend/backlog_app/_helpers/slug_helper.py +++ b/backend/backlog_app/_helpers/slug_helper.py @@ -1,8 +1,9 @@ -from models import Movie 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) diff --git a/backend/backlog_app/api/crud.py b/backend/backlog_app/api/crud.py index 175c142..6b759f3 100644 --- a/backend/backlog_app/api/crud.py +++ b/backend/backlog_app/api/crud.py @@ -1,13 +1,13 @@ import logging from uuid import UUID -from _helpers.slug_helper import generate_unique_slug from fastapi import HTTPException from sqlalchemy import or_ from sqlalchemy.ext.asyncio import AsyncSession 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 diff --git a/backend/tests/test_schemas/test_movie.py b/backend/tests/test_schemas/test_movie.py index 85620bf..f859969 100644 --- a/backend/tests/test_schemas/test_movie.py +++ b/backend/tests/test_schemas/test_movie.py @@ -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 @@ -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, @@ -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, @@ -57,7 +58,7 @@ def test_movie_full_shema(): def test_movie_read_fields_contract(): expected_fields = { - "id", + "slug", "title", "description", "note", @@ -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): @@ -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, @@ -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", From f44f79a9b7bad46b5a7c148236c669e1fd4aefe7 Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Fri, 5 Jun 2026 23:15:25 +0300 Subject: [PATCH 9/9] field slug is nullable true for production release --- .../2026_06_05_2156-4dc9128d633a_add_slug_to_movie_table.py | 2 +- backend/backlog_app/models/movie.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/backlog_app/alembic/versions/2026_06_05_2156-4dc9128d633a_add_slug_to_movie_table.py b/backend/backlog_app/alembic/versions/2026_06_05_2156-4dc9128d633a_add_slug_to_movie_table.py index a3d2433..426341e 100644 --- a/backend/backlog_app/alembic/versions/2026_06_05_2156-4dc9128d633a_add_slug_to_movie_table.py +++ b/backend/backlog_app/alembic/versions/2026_06_05_2156-4dc9128d633a_add_slug_to_movie_table.py @@ -20,7 +20,7 @@ def upgrade() -> None: """Upgrade schema.""" - op.add_column("movies", sa.Column("slug", sa.String(length=255), nullable=False)) + 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) diff --git a/backend/backlog_app/models/movie.py b/backend/backlog_app/models/movie.py index 93cc389..bd2e4f7 100644 --- a/backend/backlog_app/models/movie.py +++ b/backend/backlog_app/models/movie.py @@ -22,7 +22,7 @@ class Movie(Base): slug: Mapped[str | None] = mapped_column( String(255), unique=True, - nullable=False, + nullable=True, default="", index=True, )