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
31 changes: 31 additions & 0 deletions backend/backlog_app/api/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from uuid import UUID

from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
Expand Down Expand Up @@ -42,6 +43,36 @@ async def get_movies(db: AsyncSession, user_id: str | None = None) -> MovieList:
return MovieList.model_validate({"movies": movies})


async def get_random_movie_pool(
db: AsyncSession,
user_id: UUID,
exclude_ids: list[int] | None = None,
) -> list[Movie]:
if exclude_ids is None:
exclude_ids = []

query = (
select(Movie)
.options(selectinload(Movie.user))
.where(
or_(Movie.published.is_(True), Movie.user_id == user_id),
Movie.watched.is_(False),
)
)

if exclude_ids:
query = query.where(Movie.id.notin_(exclude_ids))

result = await db.execute(query)
pool = result.scalars().all()

logger.debug(
"Random movie pool size: %s (excluded: %s)", len(pool), len(exclude_ids)
)

return list(pool)


async def get_movie_by_id(
db: AsyncSession, movie_id: int, user_id: UUID | None = None
) -> MovieRead | None:
Expand Down
21 changes: 20 additions & 1 deletion backend/backlog_app/api/view/movie_view.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import random as stdlib_random
from typing import Annotated

from fastapi import APIRouter, BackgroundTasks, Depends, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession

from backlog_app.api import crud
Expand Down Expand Up @@ -40,6 +41,24 @@ async def get_movie_list(
return movies


@router.get("/random", response_model=MovieRead)
async def get_random_movie(
db: Annotated[AsyncSession, Depends(get_async_session)],
user: Annotated[User, Depends(current_active_user)],
exclude_ids: list[int] = Query(default=[]),
):
pool = await crud.get_random_movie_pool(db, user.id, exclude_ids)

if not pool:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No unwatched movies available",
)

chosen = stdlib_random.choice(pool)
return chosen


@router.get("/{movie_id}", response_model=MovieRead)
async def get_movie_by_id(
movie_id: int,
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/api/movies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export const moviesApi = {
return data
},

async getRandom(excludeIds: number[] = []): Promise<MovieRead> {
const params = new URLSearchParams()
excludeIds.forEach((id) => params.append('exclude_ids', String(id)))
const { data } = await api.get<MovieRead>('/movies/random', { params })
return data
},

async create(movie: MovieCreate): Promise<MovieRead> {
const { data } = await api.post<MovieRead>('/movies/', movie)
return data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,21 @@
<p class="font-mono text-xs tracking-widest text-base-400 uppercase mb-2">Коллекция</p>
<h1 class="font-display text-3xl font-bold text-base-900">Мои фильмы</h1>
</div>
<button @click="showAddModal = true" class="btn-primary shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Добавить фильм
</button>

<div class="flex items-center gap-3 shrink-0">
<!-- Roulette button -->
<button @click="roulette.open()" class="btn-secondary">
<span class="text-base leading-none">🎲</span>
Не знаю что смотреть
</button>

<button @click="showAddModal = true" class="btn-primary">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Добавить фильм
</button>
</div>
</div>

<!-- Filters -->
Expand All @@ -37,8 +46,8 @@
</button>

<div class="ml-auto">
<BaseToggle v-model="onlyMine" label="Только мои" @update:modelValue="toggleOnlyMine" />
</div>
<BaseToggle v-model="onlyMine" label="Только мои" @update:modelValue="toggleOnlyMine" />
</div>
</div>

<!-- Stats bar -->
Expand Down Expand Up @@ -95,6 +104,18 @@
@submit="handleMovieSubmit"
/>

<!-- Movie Roulette Modal -->
<MovieRoulette
:show="roulette.isOpen.value"
:movie="roulette.pickedMovie.value"
:loading="roulette.loading.value"
:exhausted="roulette.exhausted.value"
:rejected-count="roulette.excludedIds.value.length"
@close="roulette.close()"
@reject="roulette.reject()"
@restart="roulette.restart()"
/>

<!-- Delete confirm -->
<Teleport to="body">
<Transition name="modal">
Expand All @@ -118,17 +139,21 @@

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useMoviesStore } from '@/stores/movies'
import type { MovieRead } from '@/api/movies'
import { useMoviesStore } from '@/stores/movies.ts'
import type { MovieRead } from '@/api/movies.ts'
import AppHeader from '@/components/layout/AppHeader.vue'
import MovieCard from '@/components/ui/MovieCard.vue'
import AddMovieModal from '@/components/ui/AddMovieModal.vue'
import AlertMessage from '@/components/ui/AlertMessage.vue'
import BaseToggle from '@/components/ui/BaseToggle.vue'
import { useToast } from '@/composables/useToast'
import MovieRoulette from '@/components/ui/MovieRoulette.vue'
import { useToast } from '@/composables/useToast.ts'
import { useMovieRoulette } from '@/composables/useMovieRoulette'

const store = useMoviesStore()
const toast = useToast()
const roulette = useMovieRoulette()

const showAddModal = ref(false)
const editingMovie = ref<MovieRead | null>(null)
const deletingMovie = ref<MovieRead | null>(null)
Expand Down
Loading
Loading