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
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""add reading date automation settings

Revision ID: d7e8f9a0b1c2
Revises: 0a1b2c3d4e5f
Create Date: 2026-09-14 00:00:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = "d7e8f9a0b1c2"
down_revision: Union[str, Sequence[str], None] = "0a1b2c3d4e5f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"usersettings",
sa.Column("auto_set_date_started", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.add_column(
"usersettings",
sa.Column("auto_set_date_finished", sa.Boolean(), nullable=False, server_default=sa.true()),
)


def downgrade() -> None:
op.drop_column("usersettings", "auto_set_date_finished")
op.drop_column("usersettings", "auto_set_date_started")
2 changes: 2 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ class UserSettings(SQLModel, table=True):
goal_books_per_year_enabled: bool = Field(default=False)
goal_books_per_year: int = Field(default=25, ge=1)
gamification_enabled: bool = Field(default=True)
auto_set_date_started: bool = Field(default=True)
auto_set_date_finished: bool = Field(default=True)
statistics_range: str = Field(default="alltime", max_length=20)
statistics_custom_from: Optional[date] = Field(default=None)
statistics_custom_to: Optional[date] = Field(default=None)
Expand Down
82 changes: 67 additions & 15 deletions backend/app/routers/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from app.auth import require_user
from app.config import settings
from app.database import get_session
from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Medium, ReadingProgress, ReadingStatus, Tag, User
from app.models import AcquisitionStatus, Author, Book, BookAuthor, BookTag, Medium, ReadingProgress, ReadingStatus, Tag, User, UserSettings
from app.schemas import (
BookCreate,
BookListResponse,
Expand Down Expand Up @@ -60,23 +60,36 @@ def _utcnow() -> datetime:
return utcnow()


def _reading_date_automation_settings(session: Session, user_id: int) -> tuple[bool, bool]:
"""Return start/finish date automation preferences, defaulting to enabled."""
settings = session.exec(
select(UserSettings).where(UserSettings.user_id == user_id)
).first()
if settings is None:
return True, True
return settings.auto_set_date_started, settings.auto_set_date_finished


def _apply_status_transition_dates(
book: Book,
target_status: ReadingStatus,
update_data: dict,
skip_auto_date_started: bool = False,
*,
auto_set_date_started: bool = True,
auto_set_date_finished: bool = True,
) -> None:
"""Auto-fill date_started / date_finished when transitioning to a new status."""
if target_status == book.reading_status:
return

if target_status == ReadingStatus.currently_reading:
if target_status == ReadingStatus.currently_reading and auto_set_date_started:
if skip_auto_date_started:
update_data.setdefault("date_started", None)
elif book.date_started is None and update_data.get("date_started") is None:
update_data["date_started"] = _utcnow()

if target_status in (ReadingStatus.read, ReadingStatus.did_not_finish):
if target_status in (ReadingStatus.read, ReadingStatus.did_not_finish) and auto_set_date_finished:
if update_data.get("date_finished") is None:
update_data["date_finished"] = _utcnow()

Expand Down Expand Up @@ -105,6 +118,7 @@ def _validate_date_finished_for_read(
book: Book,
update_data: dict,
target_status: ReadingStatus,
auto_set_date_finished: bool = True,
) -> None:
"""Ensure date_finished is not explicitly cleared while the book is read."""
if "date_finished" not in update_data:
Expand All @@ -113,7 +127,11 @@ def _validate_date_finished_for_read(
return
if book.date_finished is None:
return
if book.reading_status == ReadingStatus.read and target_status == ReadingStatus.read:
if (
auto_set_date_finished
and book.reading_status == ReadingStatus.read
and target_status == ReadingStatus.read
):
raise HTTPException(status_code=422, detail="A finished book must have an end date. Change the status if you want to remove the finish date.")


Expand Down Expand Up @@ -242,11 +260,14 @@ def list_books(
book_ids = [b.id for b in books if b.id is not None]
book_tags_map = load_tags_batch(session, book_ids) if book_ids else {}
book_authors_map = load_authors_batch(session, book_ids) if book_ids else {}
return BookListResponse(
books=[
book_reads: list[BookRead] = []
for book in books:
assert book.id is not None
book_reads.append(
_build_book_read_with_tags(book, book_tags_map.get(book.id), book_authors_map.get(book.id))
for book in books
],
)
return BookListResponse(
books=book_reads,
total=total,
)

Expand Down Expand Up @@ -511,6 +532,9 @@ async def update_book(
)
authors_provided = authors_payload is not None
target_status = update_data.get("reading_status", book.reading_status)
auto_set_date_started, auto_set_date_finished = _reading_date_automation_settings(
session, current_user.id
)

# Download external cover URL -> local file.
if "cover_url" in update_data and is_external_cover_url(update_data["cover_url"]):
Expand Down Expand Up @@ -540,9 +564,20 @@ async def update_book(
if not shared:
delete_cover_file(old_filename, settings.covers_dir)

_apply_status_transition_dates(book, target_status, update_data)
_apply_status_transition_dates(
book,
target_status,
update_data,
auto_set_date_started=auto_set_date_started,
auto_set_date_finished=auto_set_date_finished,
)
_validate_dates(update_data)
_validate_date_finished_for_read(book, update_data, target_status)
_validate_date_finished_for_read(
book,
update_data,
target_status,
auto_set_date_finished=auto_set_date_finished,
)

book.sqlmodel_update(update_data)
session.add(book)
Expand Down Expand Up @@ -577,6 +612,7 @@ def transition_status(
session: Session = Depends(get_session),
) -> StatusTransitionResponse:
"""Change a book's reading status with date-conflict detection and resolution."""
assert current_user.id is not None
logger.debug(
"transition_status — id=%s new_status=%s force_date_started=%r force_date_finished=%r",
book_id, transition.new_status, transition.force_date_started, transition.force_date_finished,
Expand All @@ -588,6 +624,9 @@ def transition_status(
conflict: DateConflict | None = None
update_data: dict = {"reading_status": transition.new_status}
now = _utcnow()
auto_set_date_started, auto_set_date_finished = _reading_date_automation_settings(
session, current_user.id
)

# date_finished handling is split into two passes:
# 1. Inline below — conflict detection when moving TO read/did_not_finish
Expand All @@ -602,17 +641,17 @@ def transition_status(
and book.date_started is not None
and not transition.skip_auto_date_started
):
if transition.force_date_started is None:
if transition.force_date_started is None and auto_set_date_started:
conflict = DateConflict(
field="date_started",
existing_date=book.date_started,
suggested_date=now,
)
return StatusTransitionResponse(book=build_book_read(session, book), date_conflict=conflict)
update_data["date_started"] = transition.force_date_started

if (
book.date_finished is not None
transition.force_date_started is not None
and book.date_finished is not None
and transition.force_date_started > book.date_finished
):
conflict = DateConflict(
Expand Down Expand Up @@ -642,6 +681,7 @@ def transition_status(
and book.date_started is None
and book.date_finished is not None
and transition.force_date_started is None
and auto_set_date_started
and not transition.skip_auto_date_started
):
conflict = DateConflict(
Expand Down Expand Up @@ -681,9 +721,21 @@ def transition_status(
if transition.force_date_finished is not None:
update_data["date_finished"] = transition.force_date_finished

_apply_status_transition_dates(book, transition.new_status, update_data, transition.skip_auto_date_started)
_apply_status_transition_dates(
book,
transition.new_status,
update_data,
transition.skip_auto_date_started,
auto_set_date_started=auto_set_date_started,
auto_set_date_finished=auto_set_date_finished,
)
_validate_dates(update_data)
_validate_date_finished_for_read(book, update_data, transition.new_status)
_validate_date_finished_for_read(
book,
update_data,
transition.new_status,
auto_set_date_finished=auto_set_date_finished,
)
book.sqlmodel_update(update_data)
session.add(book)
session.commit()
Expand Down
13 changes: 10 additions & 3 deletions backend/app/routers/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
EmbedTokenRead,
EmbedTokenUpdate,
ProfileUpdate,
StatisticsRange,
UserRead,
UserSettingsRead,
UserSettingsUpdate,
Expand Down Expand Up @@ -99,6 +100,7 @@ def get_settings(
session.add(settings)
session.commit()
session.refresh(settings)
assert settings.user_id is not None
return UserSettingsRead(
user_id=settings.user_id,
language=settings.language,
Expand All @@ -114,7 +116,9 @@ def get_settings(
goal_books_per_year_enabled=settings.goal_books_per_year_enabled,
goal_books_per_year=settings.goal_books_per_year,
gamification_enabled=settings.gamification_enabled,
statistics_range=settings.statistics_range,
auto_set_date_started=settings.auto_set_date_started,
auto_set_date_finished=settings.auto_set_date_finished,
statistics_range=StatisticsRange(settings.statistics_range),
statistics_custom_from=settings.statistics_custom_from,
statistics_custom_to=settings.statistics_custom_to,
)
Expand Down Expand Up @@ -151,6 +155,7 @@ def update_settings(
session.add(settings)
session.commit()
session.refresh(settings)
assert settings.user_id is not None
return UserSettingsRead(
user_id=settings.user_id,
language=settings.language,
Expand All @@ -166,7 +171,9 @@ def update_settings(
goal_books_per_year_enabled=settings.goal_books_per_year_enabled,
goal_books_per_year=settings.goal_books_per_year,
gamification_enabled=settings.gamification_enabled,
statistics_range=settings.statistics_range,
auto_set_date_started=settings.auto_set_date_started,
auto_set_date_finished=settings.auto_set_date_finished,
statistics_range=StatisticsRange(settings.statistics_range),
statistics_custom_from=settings.statistics_custom_from,
statistics_custom_to=settings.statistics_custom_to,
)
Expand Down Expand Up @@ -369,7 +376,7 @@ def rotate_embed_token(

token.revoked_at = now
session.add(token)

assert current_user.id is not None
plain_token = generate_embed_token()
new_token = EmbedToken(
user_id=current_user.id,
Expand Down
12 changes: 12 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,8 @@ class UserSettingsRead(SQLModel):
goal_books_per_year_enabled: bool
goal_books_per_year: int
gamification_enabled: bool
auto_set_date_started: bool
auto_set_date_finished: bool
statistics_range: StatisticsRange
statistics_custom_from: Optional[date] = None
statistics_custom_to: Optional[date] = None
Expand All @@ -453,10 +455,19 @@ class UserSettingsUpdate(SQLModel):
goal_books_per_year_enabled: Optional[bool] = None
goal_books_per_year: Optional[int] = Field(default=None, ge=1)
gamification_enabled: Optional[bool] = None
auto_set_date_started: Optional[bool] = None
auto_set_date_finished: Optional[bool] = None
statistics_range: Optional[StatisticsRange] = None
statistics_custom_from: Optional[date] = None
statistics_custom_to: Optional[date] = None

@field_validator("auto_set_date_started", "auto_set_date_finished")
@classmethod
def validate_date_automation_setting(cls, value: Optional[bool]) -> Optional[bool]:
if value is None:
raise ValueError("Reading date automation settings cannot be null")
return value

@field_validator('theme')
@classmethod
def validate_theme(cls, v: Optional[str]) -> Optional[str]:
Expand Down Expand Up @@ -715,6 +726,7 @@ class DataImportPreviewRow(SQLModel):
source: dict[str, Any]
transformed: dict[str, Any]
errors: list[str]
warnings: list[str] = Field(default_factory=list)


class DataImportPreviewRequest(SQLModel):
Expand Down
4 changes: 3 additions & 1 deletion backend/app/services/data_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,7 @@ def preview_import(

for idx, row in enumerate(rows[:limit], start=1):
row_errors: list[str] = []
row_warnings: list[str] = []
row_data = _mapped_row(row, mapping, transform_cache, {"row": idx, "total": len(rows)}, row_errors)

# Validate required fields and data types for preview
Expand Down Expand Up @@ -742,7 +743,7 @@ def preview_import(
)

if reading_status == ReadingStatus.read and not date_finished:
row_errors.append(
row_warnings.append(
"Marked as 'read' but has no finished date; "
"without a finish date the book will not count toward monthly statistics"
)
Expand All @@ -757,6 +758,7 @@ def preview_import(
"source": source_display,
"transformed": transformed_display,
"errors": row_errors,
"warnings": row_warnings,
})

return {"preview_rows": preview_rows, "row_count": len(rows), "errors": []}
Expand Down
Loading