feat(api): per-device saved searches (keyword favorites)
User-report (mobilism): scenes are often poorly titled, so saved keyword queries are a useful extra retrieval strategy. New saved_searches table (device-scoped via X-Device-Id, unique per device+query, 50/device cap) + GET/POST/DELETE /saved-searches. Migration 0024. Verified CRUD on prod: add trims+dedups idempotently, empty rejected 422, delete idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0424cb9138
commit
bcee5851e9
5 changed files with 187 additions and 0 deletions
43
alembic/versions/20260616_0024_saved_searches.py
Normal file
43
alembic/versions/20260616_0024_saved_searches.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""saved searches: per-device saved keyword queries
|
||||||
|
|
||||||
|
Revision ID: 0024_saved_searches
|
||||||
|
Revises: 0023_bug_report_replies
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
|
||||||
|
Zapisane słowa kluczowe per urządzenie (user-report mobilism: sceny słabo opisane →
|
||||||
|
dodatkowe strategie wyszukiwania). Scope po device_id (X-Device-Id), unikat na
|
||||||
|
(device_id, query) żeby ten sam zapis był idempotentny.
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0024_saved_searches"
|
||||||
|
down_revision: str | None = "0023_bug_report_replies"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"saved_searches",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("device_id", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("query", sa.String(length=256), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name="pk_saved_searches"),
|
||||||
|
sa.UniqueConstraint("device_id", "query", name="uq_saved_searches_device_query"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_saved_searches_device_id", "saved_searches", ["device_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_saved_searches_device_id", table_name="saved_searches")
|
||||||
|
op.drop_table("saved_searches")
|
||||||
108
app/api/saved_searches.py
Normal file
108
app/api/saved_searches.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""Saved searches — zapisane słowa kluczowe per urządzenie.
|
||||||
|
|
||||||
|
User-report mobilism: sceny słabo opisane → user chce zapisywać często używane
|
||||||
|
zapytania i szybko je odpalać. Scope po device_id (X-Device-Id), jak reszta stanu.
|
||||||
|
|
||||||
|
Endpointy:
|
||||||
|
GET /saved-searches — lista zapisów tego urządzenia (najnowsze pierwsze)
|
||||||
|
POST /saved-searches — dodaj {query} (idempotent na device+query)
|
||||||
|
DELETE /saved-searches/{id} — usuń
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.device import get_device_id
|
||||||
|
from app.auth import require_api_key
|
||||||
|
from app.db import get_session
|
||||||
|
from app.models.saved_search import SavedSearch
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/saved-searches",
|
||||||
|
tags=["saved-searches"],
|
||||||
|
dependencies=[Depends(require_api_key)],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Limit zapisów per urządzenie — to wygodny skrót, nie nieograniczona kolekcja.
|
||||||
|
MAX_PER_DEVICE = 50
|
||||||
|
|
||||||
|
|
||||||
|
class SavedSearchOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
query: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SavedSearchIn(BaseModel):
|
||||||
|
query: str
|
||||||
|
|
||||||
|
@field_validator("query")
|
||||||
|
@classmethod
|
||||||
|
def _clean(cls, v: str) -> str:
|
||||||
|
v = (v or "").strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("query empty")
|
||||||
|
return v[:256]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[SavedSearchOut])
|
||||||
|
def list_saved_searches(
|
||||||
|
session: Annotated[Session, Depends(get_session)],
|
||||||
|
device_id: Annotated[str, Depends(get_device_id)],
|
||||||
|
) -> list[SavedSearch]:
|
||||||
|
rows = session.execute(
|
||||||
|
select(SavedSearch)
|
||||||
|
.where(SavedSearch.device_id == device_id)
|
||||||
|
.order_by(SavedSearch.created_at.desc())
|
||||||
|
).scalars().all()
|
||||||
|
return list(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SavedSearchOut, status_code=status.HTTP_200_OK)
|
||||||
|
def add_saved_search(
|
||||||
|
payload: SavedSearchIn,
|
||||||
|
session: Annotated[Session, Depends(get_session)],
|
||||||
|
device_id: Annotated[str, Depends(get_device_id)],
|
||||||
|
) -> SavedSearch:
|
||||||
|
# Idempotent — ten sam (device, query) zwraca istniejący wiersz.
|
||||||
|
existing = session.execute(
|
||||||
|
select(SavedSearch).where(
|
||||||
|
SavedSearch.device_id == device_id, SavedSearch.query == payload.query
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
count = session.execute(
|
||||||
|
select(SavedSearch.id).where(SavedSearch.device_id == device_id)
|
||||||
|
).scalars().all()
|
||||||
|
if len(count) >= MAX_PER_DEVICE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"saved-search limit ({MAX_PER_DEVICE}) reached",
|
||||||
|
)
|
||||||
|
row = SavedSearch(device_id=device_id, query=payload.query)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{search_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def remove_saved_search(
|
||||||
|
search_id: uuid.UUID,
|
||||||
|
session: Annotated[Session, Depends(get_session)],
|
||||||
|
device_id: Annotated[str, Depends(get_device_id)],
|
||||||
|
) -> None:
|
||||||
|
row = session.get(SavedSearch, search_id)
|
||||||
|
# Tylko własne urządzenie może usuwać; cudze/nieistniejące → idempotentny no-op.
|
||||||
|
if row is None or row.device_id != device_id:
|
||||||
|
return
|
||||||
|
session.delete(row)
|
||||||
|
session.commit()
|
||||||
|
|
@ -18,6 +18,7 @@ from app.api.me import router as me_router
|
||||||
from app.api.movies import router as movies_router
|
from app.api.movies import router as movies_router
|
||||||
from app.api.playback import movies_router as movies_playback_router
|
from app.api.playback import movies_router as movies_playback_router
|
||||||
from app.api.playback import router as playback_router
|
from app.api.playback import router as playback_router
|
||||||
|
from app.api.saved_searches import router as saved_searches_router
|
||||||
from app.api.scene_favorites import router as scene_favorites_router
|
from app.api.scene_favorites import router as scene_favorites_router
|
||||||
from app.api.scenes import router as scenes_router
|
from app.api.scenes import router as scenes_router
|
||||||
from app.api.seo import router as seo_router
|
from app.api.seo import router as seo_router
|
||||||
|
|
@ -79,6 +80,7 @@ app.include_router(taxonomies_router)
|
||||||
app.include_router(favorites_router)
|
app.include_router(favorites_router)
|
||||||
app.include_router(blacklist_router)
|
app.include_router(blacklist_router)
|
||||||
app.include_router(bug_reports_router)
|
app.include_router(bug_reports_router)
|
||||||
|
app.include_router(saved_searches_router)
|
||||||
app.include_router(expo_updates_router)
|
app.include_router(expo_updates_router)
|
||||||
app.include_router(watch_router)
|
app.include_router(watch_router)
|
||||||
app.include_router(me_router)
|
app.include_router(me_router)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from app.models.movie_playback_source import MoviePlaybackSource
|
||||||
from app.models.performer import Performer, PerformerAlias, PerformerExternalRef
|
from app.models.performer import Performer, PerformerAlias, PerformerExternalRef
|
||||||
from app.models.play_progress import MoviePlayProgress, ScenePlayProgress
|
from app.models.play_progress import MoviePlayProgress, ScenePlayProgress
|
||||||
from app.models.playback_source import PlaybackSource
|
from app.models.playback_source import PlaybackSource
|
||||||
|
from app.models.saved_search import SavedSearch
|
||||||
from app.models.scene import (
|
from app.models.scene import (
|
||||||
Scene,
|
Scene,
|
||||||
SceneExternalRef,
|
SceneExternalRef,
|
||||||
|
|
@ -55,6 +56,7 @@ __all__ = [
|
||||||
"MoviePlayProgress",
|
"MoviePlayProgress",
|
||||||
"ScenePlayProgress",
|
"ScenePlayProgress",
|
||||||
"PlaybackSource",
|
"PlaybackSource",
|
||||||
|
"SavedSearch",
|
||||||
"Scene",
|
"Scene",
|
||||||
"SceneExternalRef",
|
"SceneExternalRef",
|
||||||
"SceneFingerprint",
|
"SceneFingerprint",
|
||||||
|
|
|
||||||
32
app/models/saved_search.py
Normal file
32
app/models/saved_search.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Saved searches — zapisane słowa kluczowe per urządzenie.
|
||||||
|
|
||||||
|
User-report mobilism: sceny bywają słabo opisane, więc potrzeba dodatkowych strategii
|
||||||
|
wyszukiwania — zapisywanie często używanych zapytań. Scope per device_id (X-Device-Id),
|
||||||
|
jak reszta stanu usera. Unikat (device_id, query) → ten sam zapis idempotentny.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, UniqueConstraint, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SavedSearch(Base):
|
||||||
|
__tablename__ = "saved_searches"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("device_id", "query", name="uq_saved_searches_device_query"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
device_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
query: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue