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
49 changes: 40 additions & 9 deletions api/v1/routes/auth.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -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


Expand All @@ -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 = (
Expand All @@ -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)
47 changes: 22 additions & 25 deletions api/v1/routes/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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).
Expand All @@ -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:
Expand Down Expand Up @@ -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
):
Expand Down Expand Up @@ -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}
90 changes: 90 additions & 0 deletions api/v1/routes/shelves.py
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions api/v1/schemas/shelf_schemas.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions api/v1/schemas/user_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ class Config:
from_attributes = True
extra = "forbid"
validate_by_name = True


class APIKeyResponse(BaseModel):
api_key: str
Loading
Loading