diff --git a/api/v1/routes/auth.py b/api/v1/routes/auth.py index 418c6d7..f7672f6 100644 --- a/api/v1/routes/auth.py +++ b/api/v1/routes/auth.py @@ -1,10 +1,18 @@ +import hashlib +import secrets + from fastapi import APIRouter, Depends, HTTPException, Security, status from fastapi.security import OAuth2PasswordRequestForm -from sqlalchemy import update +from sqlalchemy import or_, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select -from api.v1.schemas.user_schemas import UserCreate, UserPreferencesUpdate, UserRead +from api.v1.schemas.user_schemas import ( + APIKeyResponse, + UserCreate, + UserPreferencesUpdate, + UserRead, +) from core.auth import ( authenticate_user, create_access_token, @@ -52,10 +60,20 @@ async def register_user( user_create: UserCreate, db: AsyncSession = Depends(get_database), ): - result = await db.execute(select(User).where(User.username == user_create.username)) + result = await db.execute( + select(User).where( + or_( + User.username == user_create.username, + User.email == user_create.email, + ), + ), + ) if result.scalars().first(): - raise HTTPException(status_code=400, detail="Username is already taken.") + raise HTTPException( + status_code=400, + detail="Username or email is already taken.", + ) user = User( id=generate_crockford_id(), @@ -76,10 +94,7 @@ async def register_user( response_model=UserRead, summary="Get current user details", ) -async def get_user_preferences( - db: AsyncSession = Depends(get_database), - current_user: User = Security(get_current_user), -): +async def read_current_user(current_user: User = Security(get_current_user)): return current_user @@ -99,7 +114,7 @@ async def update_user_preferences( new_preferences = { **current_preferences, - **preferences_update.model_dump(), + **preferences_update.model_dump(exclude_unset=True), } query = ( @@ -114,3 +129,19 @@ async def update_user_preferences( await db.refresh(current_user) return current_user + + +@router.post( + "/api-key", + response_model=APIKeyResponse, + summary="Generate a new API key", +) +async def generate_api_key( + db: AsyncSession = Depends(get_database), + current_user: User = Security(get_current_user), +): + key = secrets.token_urlsafe(32) + hashed = hashlib.sha256(key.encode()).hexdigest() + current_user.api_key_hash = hashed + await db.commit() + return APIKeyResponse(api_key=key) diff --git a/api/v1/routes/books.py b/api/v1/routes/books.py index 17a7d03..3c31caf 100644 --- a/api/v1/routes/books.py +++ b/api/v1/routes/books.py @@ -112,13 +112,18 @@ async def list_books( skip: int = Query(0, ge=0), limit: int = Query(10, ge=1, le=100), search: str | None = Query(None), + tags: list[str] | None = Query(None), + sort_by: str = Query("title", pattern="^(title|uploaded_at)$"), + sort_order: str = Query("asc", pattern="^(asc|desc)$"), book_service: BookService = Depends(get_book_service), - user=Security(get_current_user), + user: User = Security(get_current_user), ): """ - Lists books with pagination and optional search. + Lists books with pagination, optional search, filtering and sorting. """ - books, total = await book_service.get_books(user.id, skip, limit, search) + books, total = await book_service.get_books( + user.id, skip, limit, search, tags, sort_by, sort_order, + ) items = [construct_book_display(book, request) for book in books] return PaginatedBookResponse(items=items, total=total) @@ -132,15 +137,12 @@ async def get_book( book_id: str, request: Request, book_service: BookService = Depends(get_book_service), - user=Security(get_current_user), + user: User = Security(get_current_user), ): """ Retrieves metadata for a specific book. """ - book = await book_service.get_book_by_id(book_id) - - if not book or book.user_id != user.id: - raise HTTPException(status_code=404, detail="Book not found.") + book = await book_service.get_book_by_id(book_id, user.id) return construct_book_display(book.__dict__, request) @@ -155,15 +157,15 @@ async def update_book( book_update: BookUpdate, request: Request, book_service: BookService = Depends(get_book_service), - user=Security(get_current_user), + user: User = Security(get_current_user), ): """ Updates metadata for a specific book. """ - updated = await book_service.update_book_by_id(book_id, book_update) + updated = await book_service.update_book_by_id(book_id, book_update, user.id) if not updated: - raise HTTPException(status_code=404, detail="Book not found") + raise HTTPException(status_code=404, detail="Book not found.") return construct_book_display(updated.__dict__, request) @@ -176,7 +178,7 @@ async def update_book( async def delete_book( book_id: str, book_service: BookService = Depends(get_book_service), - user=Security(get_current_user), + user: User = Security(get_current_user), ): """ Deletes an book (metadata and associated files). @@ -203,17 +205,17 @@ async def get_book_cover( examples=["thumbnail", "original"], ), book_service: BookService = Depends(get_book_service), - user=Security(get_current_user), + user: User = Security(get_current_user), ): """ Retrieves the cover image for a book. Optionally, a variant can be requested. Available variants depend on the implementation and may include 'thumbnail', 'original', etc. """ - book = await book_service.get_book_by_id(book_id) + book = await book_service.get_book_by_id(book_id, user.id) storage_backend = await book_service.get_storage_backend(user) - if book.user_id != user.id or not book.file_hash: + if not book.file_hash: raise HTTPException(status_code=404, detail="Book does not exist.") if variant is None: @@ -250,17 +252,15 @@ async def download_book_file( book_id: str, background_tasks: BackgroundTasks, book_service: BookService = Depends(get_book_service), - user=Security(get_current_user), + user: User = Security(get_current_user), ): """ Download the book file for a specific book. """ - book = await book_service.get_book_by_id(book_id) + book = await book_service.get_book_by_id(book_id, user.id) if ( - not book - or book.user_id != user.id - or not book.file_path + not book.file_path or not book.file_hash or not book.stored_filename ): @@ -298,11 +298,8 @@ async def download_book_file( async def get_book_status( book_id: str, book_service: BookService = Depends(get_book_service), - user=Security(get_current_user), + user: User = Security(get_current_user), ): - book = await book_service.get_book_by_id(book_id) - - if not book: - raise HTTPException(status_code=404, detail="Book not found.") + book = await book_service.get_book_by_id(book_id, user.id) return {"status": book.status, "error": book.processing_error} diff --git a/api/v1/routes/shelves.py b/api/v1/routes/shelves.py new file mode 100644 index 0000000..8ec0a69 --- /dev/null +++ b/api/v1/routes/shelves.py @@ -0,0 +1,90 @@ +from fastapi import APIRouter, Depends, Security + +from api.v1.schemas.shelf_schemas import ShelfCreate, ShelfRead +from core.auth import get_current_user +from models.user import User +from services.book_service import BookService, get_book_service +from services.shelf_service import ShelfService, get_shelf_service + +router = APIRouter() + + +@router.post( + "/", + response_model=ShelfRead, + status_code=201, + summary="Create a new shelf", +) +async def create_shelf( + shelf: ShelfCreate, + shelf_service: ShelfService = Depends(get_shelf_service), + user: User = Security(get_current_user), +): + created = await shelf_service.create_shelf(user.id, shelf.name) + return ShelfRead(id=created.id, name=created.name, book_ids=[b.id for b in created.books]) + + +@router.get("/", response_model=list[ShelfRead], summary="List shelves") +async def list_shelves( + shelf_service: ShelfService = Depends(get_shelf_service), + user: User = Security(get_current_user), +): + shelves = await shelf_service.list_shelves(user.id) + return [ + ShelfRead(id=s.id, name=s.name, book_ids=[b.id for b in s.books]) + for s in shelves + ] + + +@router.get( + "/{shelf_id}", response_model=ShelfRead, summary="Retrieve a shelf", +) +async def get_shelf( + shelf_id: str, + shelf_service: ShelfService = Depends(get_shelf_service), + user: User = Security(get_current_user), +): + shelf = await shelf_service.get_shelf(shelf_id, user.id) + return ShelfRead(id=shelf.id, name=shelf.name, book_ids=[b.id for b in shelf.books]) + + +@router.delete("/{shelf_id}", status_code=204, summary="Delete a shelf") +async def delete_shelf( + shelf_id: str, + shelf_service: ShelfService = Depends(get_shelf_service), + user: User = Security(get_current_user), +): + await shelf_service.delete_shelf(shelf_id, user.id) + return + + +@router.post( + "/{shelf_id}/books/{book_id}", + response_model=ShelfRead, + summary="Add a book to a shelf", +) +async def add_book_to_shelf( + shelf_id: str, + book_id: str, + shelf_service: ShelfService = Depends(get_shelf_service), + book_service: BookService = Depends(get_book_service), + user: User = Security(get_current_user), +): + book = await book_service.get_book_by_id(book_id, user.id, raw=True) + shelf = await shelf_service.add_book(shelf_id, book, user.id) + return ShelfRead(id=shelf.id, name=shelf.name, book_ids=[b.id for b in shelf.books]) + + +@router.delete( + "/{shelf_id}/books/{book_id}", status_code=204, summary="Remove a book from a shelf", +) +async def remove_book_from_shelf( + shelf_id: str, + book_id: str, + shelf_service: ShelfService = Depends(get_shelf_service), + book_service: BookService = Depends(get_book_service), + user: User = Security(get_current_user), +): + book = await book_service.get_book_by_id(book_id, user.id, raw=True) + await shelf_service.remove_book(shelf_id, book, user.id) + return diff --git a/api/v1/schemas/shelf_schemas.py b/api/v1/schemas/shelf_schemas.py new file mode 100644 index 0000000..80de0eb --- /dev/null +++ b/api/v1/schemas/shelf_schemas.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + + +class ShelfBase(BaseModel): + name: str + + +class ShelfCreate(ShelfBase): + pass + + +class ShelfRead(ShelfBase): + id: str + book_ids: list[str] = [] + + class Config: + from_attributes = True diff --git a/api/v1/schemas/user_schemas.py b/api/v1/schemas/user_schemas.py index a04aebd..30a49ff 100644 --- a/api/v1/schemas/user_schemas.py +++ b/api/v1/schemas/user_schemas.py @@ -30,3 +30,7 @@ class Config: from_attributes = True extra = "forbid" validate_by_name = True + + +class APIKeyResponse(BaseModel): + api_key: str diff --git a/core/auth.py b/core/auth.py index 5dd42a2..6306318 100644 --- a/core/auth.py +++ b/core/auth.py @@ -1,7 +1,8 @@ from datetime import UTC, datetime, timedelta +import hashlib from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer +from fastapi.security import APIKeyHeader, OAuth2PasswordBearer from jose import JWTError, jwt from passlib.context import CryptContext from sqlalchemy.ext.asyncio import AsyncSession @@ -24,7 +25,10 @@ class SecretKeyNotSetError(ValueError): ACCESS_TOKEN_EXPIRE_MINUTES = 30 password_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token") +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl="/api/v1/auth/token", auto_error=False, +) +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) def verify_password(plain_password, hashed_password): @@ -52,6 +56,12 @@ async def get_user_by_username(db: AsyncSession, username: str) -> User | None: return result.scalars().first() +async def get_user_by_api_key(db: AsyncSession, api_key: str) -> User | None: + hashed = hashlib.sha256(api_key.encode()).hexdigest() + result = await db.execute(select(User).where(User.api_key_hash == hashed)) + return result.scalars().first() + + async def authenticate_user( db: AsyncSession, username: str, @@ -66,7 +76,8 @@ async def authenticate_user( async def get_current_user( - token: str = Depends(oauth2_scheme), + api_key: str | None = Depends(api_key_header), + token: str | None = Depends(oauth2_scheme), db: AsyncSession = Depends(get_database), ): credentials_exception = HTTPException( @@ -75,18 +86,27 @@ async def get_current_user( headers={"WWW-Authenticate": "Bearer"}, ) - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - username = payload.get("sub") + if api_key: + user = await get_user_by_api_key(db, api_key) + if user: + return user + raise credentials_exception - if not isinstance(username, str): - raise credentials_exception - except JWTError as err: - raise credentials_exception from err + if token: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username = payload.get("sub") - user = await get_user_by_username(db, username) + if not isinstance(username, str): + raise credentials_exception + except JWTError as err: + raise credentials_exception from err - if user is None: - raise credentials_exception + user = await get_user_by_username(db, username) - return user + if user is None: + raise credentials_exception + + return user + + raise credentials_exception diff --git a/database/book_crud.py b/database/book_crud.py index fbc3ba8..6fe3b2f 100644 --- a/database/book_crud.py +++ b/database/book_crud.py @@ -41,17 +41,37 @@ async def get_all_books( skip: int = 0, limit: int = 10, search_query: str | None = None, + tags: list[str] | None = None, + sort_by: str = "title", + sort_order: str = "asc", ): query = select(Book).where(Book.user_id == user_id) if search_query: query = query.where(Book.title.ilike(f"%{search_query}%")) - result = await db.execute(query.offset(skip).limit(limit)) + if tags: + for tag in tags: + query = query.where(Book.tags.any(tag)) + + sort_column = getattr(Book, sort_by, Book.title) + if sort_order.lower() == "desc": + sort_column = sort_column.desc() + else: + sort_column = sort_column.asc() + + result = await db.execute(query.order_by(sort_column).offset(skip).limit(limit)) books = result.scalars().all() - count = await db.scalar( - select(func.count()).select_from(Book).where(Book.user_id == user_id), - ) + + count_query = select(func.count()).select_from(Book).where(Book.user_id == user_id) + + if search_query: + count_query = count_query.where(Book.title.ilike(f"%{search_query}%")) + if tags: + for tag in tags: + count_query = count_query.where(Book.tags.any(tag)) + + count = await db.scalar(count_query) return books, count diff --git a/database/shelf_crud.py b/database/shelf_crud.py new file mode 100644 index 0000000..76e87db --- /dev/null +++ b/database/shelf_crud.py @@ -0,0 +1,54 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload + +from core.crockford import generate_crockford_id +from models.book import Book +from models.shelf import Shelf + + +async def create_shelf(db: AsyncSession, user_id: str, name: str) -> Shelf: + shelf = Shelf(id=generate_crockford_id(), user_id=user_id, name=name) + db.add(shelf) + await db.commit() + await db.refresh(shelf) + return shelf + + +async def get_shelves(db: AsyncSession, user_id: str) -> list[Shelf]: + result = await db.execute( + select(Shelf).where(Shelf.user_id == user_id).options(selectinload(Shelf.books)), + ) + return result.scalars().all() + + +async def get_shelf( + db: AsyncSession, shelf_id: str, user_id: str, +) -> Shelf | None: + result = await db.execute( + select(Shelf) + .where(Shelf.id == shelf_id, Shelf.user_id == user_id) + .options(selectinload(Shelf.books)), + ) + return result.scalars().first() + + +async def delete_shelf(db: AsyncSession, shelf: Shelf) -> None: + await db.delete(shelf) + await db.commit() + + +async def add_book(db: AsyncSession, shelf: Shelf, book: Book) -> Shelf: + if book not in shelf.books: + shelf.books.append(book) + await db.commit() + await db.refresh(shelf) + return shelf + + +async def remove_book(db: AsyncSession, shelf: Shelf, book: Book) -> Shelf: + if book in shelf.books: + shelf.books.remove(book) + await db.commit() + await db.refresh(shelf) + return shelf diff --git a/main.py b/main.py index 49915a5..d65905f 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ from api.v1.routes import auth as auth_v1_router from api.v1.routes import books as books_v1_router +from api.v1.routes import shelves as shelves_v1_router from api.v1.routes import storage as storage_v1_router from core.config import settings @@ -42,6 +43,12 @@ async def lifespan(_app: FastAPI): tags=["Storage v1"], ) +app.include_router( + shelves_v1_router.router, + prefix="/api/v1/shelves", + tags=["Shelves v1"], +) + @app.get("/", tags=["Root"]) async def read_root(): diff --git a/models/book.py b/models/book.py index 476deea..75aa1a4 100644 --- a/models/book.py +++ b/models/book.py @@ -1,13 +1,18 @@ -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text from sqlalchemy.dialects.postgresql import ARRAY, JSON from sqlalchemy.ext.mutable import MutableList -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func from database.base import Base +if TYPE_CHECKING: + from models.shelf import Shelf + class Book(Base): __tablename__ = "books" @@ -74,3 +79,7 @@ class Book(Base): ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) + + shelves: Mapped[list[Shelf]] = relationship( + "Shelf", secondary="shelf_books", back_populates="books", + ) diff --git a/models/domain_models.py b/models/domain_models.py index 5c62c7c..614bca3 100644 --- a/models/domain_models.py +++ b/models/domain_models.py @@ -1,3 +1,4 @@ from models.book import Book # noqa: F401 +from models.shelf import Shelf # noqa: F401 from models.storage import Storage # noqa: F401 from models.user import User # noqa: F401 diff --git a/models/shelf.py b/models/shelf.py new file mode 100644 index 0000000..32d385c --- /dev/null +++ b/models/shelf.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlalchemy import Column, DateTime, ForeignKey, String, Table +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from database.base import Base + +if TYPE_CHECKING: + from models.book import Book + +shelf_books = Table( + "shelf_books", + Base.metadata, + Column("shelf_id", String, ForeignKey("shelves.id", ondelete="CASCADE"), primary_key=True), + Column("book_id", String, ForeignKey("books.id", ondelete="CASCADE"), primary_key=True), +) + + +class Shelf(Base): + __tablename__ = "shelves" + + id: Mapped[str] = mapped_column(String, primary_key=True, index=True) + name: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[Any] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[Any] = mapped_column(DateTime, onupdate=func.now(), nullable=True) + user_id: Mapped[str] = mapped_column( + String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + + books: Mapped[list[Book]] = relationship( + "Book", secondary="shelf_books", back_populates="shelves", + ) diff --git a/models/user.py b/models/user.py index a77d39b..c8ea9a5 100644 --- a/models/user.py +++ b/models/user.py @@ -31,4 +31,8 @@ class User(Base): onupdate=datetime.now, ) + api_key_hash: Mapped[str | None] = mapped_column( + String, unique=True, index=True, nullable=True, + ) + preferences: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) diff --git a/services/book_service.py b/services/book_service.py index dbc573e..4a04a8d 100644 --- a/services/book_service.py +++ b/services/book_service.py @@ -328,6 +328,9 @@ async def get_books( skip: int, limit: int, search_query: str | None, + tags: list[str] | None = None, + sort_by: str = "title", + sort_order: str = "asc", ) -> tuple[list[dict[str, Any]], int]: books, count = await book_crud.get_all_books( self.db, @@ -335,25 +338,42 @@ async def get_books( skip, limit, search_query, + tags, + sort_by, + sort_order, ) return [ BookInDB.model_validate(book.__dict__).model_dump() for book in books ], int(count or 0) - async def get_book_by_id(self, book_id: str): + async def get_book_by_id( + self, + book_id: str, + user_id: str | None = None, + raw: bool = False, + ) -> Book | BookInDB: book = await book_crud.get_book_by_id(self.db, book_id) - if not book: + if not book or (user_id and book.user_id != user_id): raise HTTPException(status_code=404, detail="Book not found.") + if raw: + return book + return BookInDB.model_validate(book.__dict__) async def update_book_by_id( self, book_id: str, book_update_data: BookUpdate, - ) -> dict[str, Any] | None: + user_id: str, + ) -> Book | None: + book = await book_crud.get_book_by_id(self.db, book_id) + + if not book or book.user_id != user_id: + return None + return await book_crud.update_book_metadata(self.db, book_id, book_update_data) async def update_book_status( diff --git a/services/shelf_service.py b/services/shelf_service.py new file mode 100644 index 0000000..430ea5e --- /dev/null +++ b/services/shelf_service.py @@ -0,0 +1,47 @@ +from fastapi import Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_database, shelf_crud +from models.book import Book +from models.shelf import Shelf + + +class ShelfService: + def __init__(self, db: AsyncSession): + self.db = db + + async def create_shelf(self, user_id: str, name: str) -> Shelf: + return await shelf_crud.create_shelf(self.db, user_id, name) + + async def list_shelves(self, user_id: str) -> list[Shelf]: + return await shelf_crud.get_shelves(self.db, user_id) + + async def get_shelf(self, shelf_id: str, user_id: str) -> Shelf: + shelf = await shelf_crud.get_shelf(self.db, shelf_id, user_id) + if not shelf: + raise HTTPException(status_code=404, detail="Shelf not found.") + return shelf + + async def delete_shelf(self, shelf_id: str, user_id: str) -> None: + shelf = await self.get_shelf(shelf_id, user_id) + await shelf_crud.delete_shelf(self.db, shelf) + + async def add_book(self, shelf_id: str, book: Book, user_id: str) -> Shelf: + shelf = await self.get_shelf(shelf_id, user_id) + + if book in shelf.books: + raise HTTPException(status_code=400, detail="Book already on shelf.") + + return await shelf_crud.add_book(self.db, shelf, book) + + async def remove_book(self, shelf_id: str, book: Book, user_id: str) -> Shelf: + shelf = await self.get_shelf(shelf_id, user_id) + + if book not in shelf.books: + raise HTTPException(status_code=404, detail="Book not on shelf.") + + return await shelf_crud.remove_book(self.db, shelf, book) + + +def get_shelf_service(database: AsyncSession = Depends(get_database)) -> ShelfService: + return ShelfService(database)