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
32 changes: 32 additions & 0 deletions backend/alembic/versions/e2f3a4b5c6d7_add_acquisition_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""add acquisition status to books

Revision ID: e2f3a4b5c6d7
Revises: 1a2b3c4d5e6f
Create Date: 2026-08-22 20:00:00.000000
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "e2f3a4b5c6d7"
down_revision: Union[str, Sequence[str], None] = "784de5d2bf69"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
with op.batch_alter_table("book") as batch_op:
batch_op.add_column(sa.Column("acquisition_status", sa.String(length=32), nullable=True))
op.execute("UPDATE book SET acquisition_status = 'owned' WHERE acquisition_status IS NULL")
with op.batch_alter_table("book") as batch_op:
batch_op.alter_column("acquisition_status", nullable=False)
batch_op.create_index("ix_book_acquisition_status", ["acquisition_status"])


def downgrade() -> None:
with op.batch_alter_table("book") as batch_op:
batch_op.drop_index("ix_book_acquisition_status")
batch_op.drop_column("acquisition_status")
20 changes: 15 additions & 5 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ class ReadingStatus(str, Enum):
did_not_finish = "did_not_finish"


class AcquisitionStatus(str, Enum):
"""Enum of a book's current availability to the user."""

owned = "owned"
borrowed = "borrowed"
digital_access = "digital_access"
to_acquire = "to_acquire"


class UserRole(str, Enum):
"""Enum of possible user roles."""

Expand Down Expand Up @@ -79,6 +88,7 @@ def normalize_empty_cover_url(cls, data: dict) -> dict:
blurb: Optional[str] = None
rating: Optional[int] = Field(default=None, ge=1, le=5)
reading_status: ReadingStatus = Field(default=ReadingStatus.want_to_read, index=True)
acquisition_status: AcquisitionStatus = Field(default=AcquisitionStatus.owned, index=True)
user_id: Optional[int] = Field(default=None, foreign_key="user.id", index=True)
date_added: datetime = Field(
default_factory=utcnow,
Expand All @@ -97,7 +107,7 @@ def normalize_empty_cover_url(cls, data: dict) -> dict:
class Tag(SQLModel, table=True):
"""A user-specific tag that can be applied to books."""

__tablename__ = "tag"
__tablename__: str = "tag"
__table_args__ = (sa.UniqueConstraint("user_id", "name", name="uq_tag_user_id_name"),)

id: Optional[int] = Field(default=None, primary_key=True)
Expand All @@ -112,7 +122,7 @@ class Tag(SQLModel, table=True):
class BookTag(SQLModel, table=True):
"""Many-to-many association between books and tags."""

__tablename__ = "book_tag"
__tablename__: str = "book_tag"

book_id: int = Field(foreign_key="book.id", primary_key=True)
tag_id: int = Field(foreign_key="tag.id", primary_key=True, index=True)
Expand Down Expand Up @@ -178,7 +188,7 @@ class ApiKey(SQLModel, table=True):
class ReadingProgress(SQLModel, table=True):
"""A page-number reading progress entry for a book."""

__tablename__ = "reading_progress"
__tablename__: str = "reading_progress"

id: Optional[int] = Field(default=None, primary_key=True)
book_id: int = Field(foreign_key="book.id", index=True)
Expand Down Expand Up @@ -212,7 +222,7 @@ class OidcLink(SQLModel, table=True):
class EmbedToken(SQLModel, table=True):
"""A scoped embed token for iframe/dashboard integrations."""

__tablename__ = "embed_token"
__tablename__: str = "embed_token"

id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
Expand Down Expand Up @@ -242,7 +252,7 @@ class EmbedToken(SQLModel, table=True):
class ImportMapping(SQLModel, table=True):
"""A saved column-mapping configuration for data import."""

__tablename__ = "import_mapping"
__tablename__: str = "import_mapping"
__table_args__ = (
sa.UniqueConstraint("user_id", "name", name="uq_import_mapping_user_id_name"),
)
Expand Down
12 changes: 8 additions & 4 deletions backend/app/routers/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
import sqlalchemy as sa
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, func, or_, select
from sqlmodel import Session, col, func, or_, select

from app.auth import require_user
from app.config import settings
from app.database import get_session
from app.models import Book, BookTag, ReadingProgress, ReadingStatus, Tag, User
from app.models import AcquisitionStatus, Book, BookTag, ReadingProgress, ReadingStatus, Tag, User
from app.schemas import (
BookCreate,
BookListResponse,
Expand Down Expand Up @@ -140,6 +140,7 @@ def _build_book_read_with_tags(book: Book, tags_text: str | None) -> BookRead:
@router.get("", response_model=BookListResponse)
def list_books(
status: Optional[ReadingStatus] = Query(default=None),
acquisition_status: Optional[AcquisitionStatus] = Query(default=None),
q: Optional[str] = Query(default=None),
has_cover: Optional[bool] = Query(default=None),
sort: Literal["title", "date_added", "date_started", "date_finished", "rating"] = Query(
Expand Down Expand Up @@ -167,11 +168,14 @@ def list_books(
if status is not None:
base_statement = base_statement.where(Book.reading_status == status)

if acquisition_status is not None:
base_statement = base_statement.where(Book.acquisition_status == acquisition_status)

if q:
pattern = f"%{q}%"
matching_tag_book_ids = select(BookTag.book_id).join(Tag, Tag.id == BookTag.tag_id).where(
matching_tag_book_ids = select(BookTag.book_id).join(Tag, col(Tag.id) == BookTag.tag_id).where(
Tag.user_id == current_user.id,
Tag.name.ilike(pattern),
col(Tag.name).ilike(pattern),
)
base_statement = base_statement.where(
or_(
Expand Down
4 changes: 3 additions & 1 deletion backend/app/routers/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ def validate_import_data(
payload = validate_import(
body.file_id, current_user, body.mapping, session,
create_progress_for_read=body.create_progress_for_read,
require_acquisition_status=True,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
Expand All @@ -274,7 +275,7 @@ def preview_import_data(
) -> DataImportPreviewResponse:
"""Preview how a mapping and transforms will affect the first rows."""
try:
payload = preview_import(body.file_id, current_user, body.mapping)
payload = preview_import(body.file_id, current_user, body.mapping, require_acquisition_status=True)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return DataImportPreviewResponse.model_validate(payload)
Expand Down Expand Up @@ -302,6 +303,7 @@ async def event_generator():
session=session,
import_mode=body.import_mode,
create_progress_for_read=body.create_progress_for_read,
require_acquisition_status=True,
):
if event.get("event") == "complete":
completed = True
Expand Down
1 change: 1 addition & 0 deletions backend/app/routers/import_.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ async def import_book(
language=_normalize_language(c.language),
blurb=c.blurb,
reading_status=body.reading_status,
acquisition_status=body.acquisition_status,
user_id=current_user.id,
)
session.add(book)
Expand Down
12 changes: 11 additions & 1 deletion backend/app/routers/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@

from app.auth import require_user
from app.database import get_session
from app.models import Book, ReadingProgress, ReadingStatus, User, UserSettings
from app.models import AcquisitionStatus, Book, ReadingProgress, ReadingStatus, User, UserSettings
from app.schemas import (
AcquisitionStatusDistribution,
DailyPages,
DailyPagesResponse,
LanguageDistribution,
Expand Down Expand Up @@ -357,6 +358,14 @@ def get_statistics(
did_not_finish=status_counts.get(ReadingStatus.did_not_finish, 0),
)

acquisition_counts = Counter(book.acquisition_status for book in books)
acquisition_status_distribution = AcquisitionStatusDistribution(
owned=acquisition_counts.get(AcquisitionStatus.owned, 0),
borrowed=acquisition_counts.get(AcquisitionStatus.borrowed, 0),
digital_access=acquisition_counts.get(AcquisitionStatus.digital_access, 0),
to_acquire=acquisition_counts.get(AcquisitionStatus.to_acquire, 0),
)

page_values = [book.page_count for book in books if book.page_count is not None]
avg_page_count = round(mean(page_values), 2) if page_values else None

Expand Down Expand Up @@ -584,6 +593,7 @@ def get_statistics(
most_popular_language_count=most_popular_language_count,
language_distribution=language_distribution,
status_distribution=status_distribution,
acquisition_status_distribution=acquisition_status_distribution,
page_buckets=page_buckets,
pages_read_per_month=pages_read_per_month,
books_finished_per_month=books_finished_per_month,
Expand Down
20 changes: 17 additions & 3 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
from enum import Enum
from typing import Literal

from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import BaseModel, field_validator
from sqlmodel import Field, SQLModel
from sqlmodel._compat import SQLModelConfig

from app.models import ReadingStatus, UserRole
from app.models import AcquisitionStatus, ReadingStatus, UserRole


class ReadingProgressCreate(SQLModel):
Expand Down Expand Up @@ -52,6 +53,7 @@ class BookCreate(SQLModel):
blurb: Optional[str] = None
rating: Optional[int] = Field(default=None, ge=1, le=5)
reading_status: ReadingStatus = ReadingStatus.want_to_read
acquisition_status: AcquisitionStatus = AcquisitionStatus.owned
date_started: Optional[datetime] = None
date_finished: Optional[datetime] = None

Expand All @@ -72,6 +74,7 @@ class BookUpdate(SQLModel):
blurb: Optional[str] = None
rating: Optional[int] = Field(default=None, ge=1, le=5)
reading_status: Optional[ReadingStatus] = None
acquisition_status: Optional[AcquisitionStatus] = None
date_started: Optional[datetime] = None
date_finished: Optional[datetime] = None

Expand Down Expand Up @@ -118,6 +121,7 @@ class BookImportRequest(SQLModel):
"""Persists a BookImportCandidate into the local DB."""
candidate: BookImportCandidate
reading_status: ReadingStatus = ReadingStatus.want_to_read
acquisition_status: AcquisitionStatus = AcquisitionStatus.owned


class BookRead(SQLModel):
Expand All @@ -137,6 +141,7 @@ class BookRead(SQLModel):
blurb: Optional[str]
rating: Optional[int]
reading_status: ReadingStatus
acquisition_status: AcquisitionStatus
date_added: datetime
date_started: Optional[datetime]
date_finished: Optional[datetime]
Expand Down Expand Up @@ -188,6 +193,14 @@ class StatusDistribution(SQLModel):
did_not_finish: int


class AcquisitionStatusDistribution(SQLModel):
"""Count of books per acquisition status."""
owned: int
borrowed: int
digital_access: int
to_acquire: int


class PageBuckets(SQLModel):
"""Page count buckets for the statistics dashboard."""
pages_to_read: int
Expand Down Expand Up @@ -248,6 +261,7 @@ class StatisticsResponse(SQLModel):
most_popular_language_count: Optional[int]
language_distribution: list[LanguageDistribution]
status_distribution: StatusDistribution
acquisition_status_distribution: AcquisitionStatusDistribution
page_buckets: PageBuckets
pages_read_per_month: list[MonthlyPages]
books_finished_per_month: list[MonthlyBooks]
Expand Down Expand Up @@ -315,7 +329,7 @@ class UserUpdate(SQLModel):

class ProfileUpdate(SQLModel):
"""Profile update request (non-admin)."""
model_config = ConfigDict(extra="forbid")
model_config = SQLModelConfig(extra="forbid")

firstname: Optional[str] = None
lastname: Optional[str] = None
Expand Down
2 changes: 2 additions & 0 deletions backend/app/services/data_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"blurb",
"rating",
"reading_status",
"acquisition_status",
"date_added",
"date_started",
"date_finished",
Expand Down Expand Up @@ -63,6 +64,7 @@ def _book_to_dict(session: Session, book: Book) -> dict:
"blurb": book.blurb,
"rating": book.rating,
"reading_status": book.reading_status.value,
"acquisition_status": book.acquisition_status.value,
"date_added": _serialize_datetime(book.date_added),
"date_started": _serialize_datetime(book.date_started),
"date_finished": _serialize_datetime(book.date_finished),
Expand Down
Loading
Loading