Skip to content
Open
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
4 changes: 3 additions & 1 deletion openlibrary/accounts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import web

from openlibrary.utils.request_context import site

# FIXME: several modules import things from accounts.model
# directly through openlibrary.accounts
from .model import * # noqa: F403
Expand Down Expand Up @@ -54,7 +56,7 @@ def get_current_user() -> "User | None":
"""
Returns the currently logged in user. None if not logged in.
"""
return web.ctx.site.get_user()
return site.get().get_user()


def find(
Expand Down
4 changes: 4 additions & 0 deletions openlibrary/asgi_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,17 @@ def health() -> dict[str, str]:
from openlibrary.fastapi.publishers import router as publishers_router
from openlibrary.fastapi.search import router as search_router
from openlibrary.fastapi.subjects import router as subjects_router
from openlibrary.fastapi.yearly_reading_goals import (
router as yearly_reading_goals_router,
)

# Include routers
app.include_router(languages_router)
app.include_router(publishers_router)
app.include_router(search_router)
app.include_router(subjects_router)
app.include_router(account_router)
app.include_router(yearly_reading_goals_router)

return app

Expand Down
124 changes: 124 additions & 0 deletions openlibrary/fastapi/yearly_reading_goals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""
FastAPI endpoints for yearly reading goals.
"""

from __future__ import annotations

from datetime import datetime
from typing import Annotated

from fastapi import APIRouter, Depends, Form
from pydantic import BaseModel, Field, model_validator

from openlibrary.core.yearly_reading_goals import YearlyReadingGoals
from openlibrary.fastapi.auth import (
AuthenticatedUser,
require_authenticated_user,
)

router = APIRouter()

MAX_READING_GOAL = 10_000


class ReadingGoalItem(BaseModel):
"""A single reading goal entry."""

year: int = Field(..., description="The year for this reading goal")
goal: int = Field(..., description="The target number of books to read")


class ReadingGoalsResponse(BaseModel):
"""Response model for reading goals GET endpoint."""

status: str = Field(default="ok", description="Response status")
goal: list[ReadingGoalItem] = Field(
default_factory=list, description="List of reading goals"
)


class ReadingGoalUpdateResponse(BaseModel):
"""Response model for reading goals POST endpoint."""

status: str = Field(default="ok", description="Response status")


class ReadingGoalForm(BaseModel):
"""Form data for creating or updating a reading goal.

Uses Pydantic model_validator to handle complex conditional validation
that depends on multiple fields (is_update flag affects goal validation).
"""

goal: int = Field(
default=0,
ge=0,
le=MAX_READING_GOAL,
description="Target number of books to read (0-10000). Use 0 with is_update to delete.",
)
year: int | None = Field(
default=None,
description="Year for this reading goal. Defaults to current year for creates, required for updates.",
)
is_update: str | None = Field(
default=None,
description="Set to any value to indicate this is an update operation (not create).",
)

@model_validator(mode='after')
def validate_goal_logic(self) -> ReadingGoalForm:
"""Validate goal based on whether this is an update or create operation.

- For creates: goal must be >= 1
- For updates: goal must be >= 0, and year is required
"""
is_update = self.is_update is not None

if is_update:
if not self.year:
raise ValueError('Year required to update reading goals')
elif not self.goal:
raise ValueError('Reading goal must be a positive integer')

return self


@router.get("/reading-goal.json", response_model=ReadingGoalsResponse)
async def get_reading_goals_endpoint(
user: Annotated[AuthenticatedUser, Depends(require_authenticated_user)],
year: int | None = None,
) -> ReadingGoalsResponse:
"""Get reading goals for the authenticated user."""
if year:
records = YearlyReadingGoals.select_by_username_and_year(user.username, year)
else:
records = YearlyReadingGoals.select_by_username(user.username)
goals = [
ReadingGoalItem(
year=getattr(record, 'year', 0),
goal=getattr(record, 'target', 0),
)
for record in records
]

return ReadingGoalsResponse(status="ok", goal=goals)


@router.post("/reading-goal.json", response_model=ReadingGoalUpdateResponse)
async def update_reading_goal_endpoint(
user: Annotated[AuthenticatedUser, Depends(require_authenticated_user)],
form: Annotated[ReadingGoalForm, Form()],
) -> ReadingGoalUpdateResponse:
"""Create or update a reading goal for the authenticated user."""
current_year = form.year or datetime.now().year
if form.is_update is not None:
# year is guaranteed to be not None here due to model_validator
assert form.year is not None
if form.goal == 0:
YearlyReadingGoals.delete_by_username_and_year(user.username, form.year)
else:
YearlyReadingGoals.update_target(user.username, form.year, form.goal)
else:
YearlyReadingGoals.create(user.username, current_year, form.goal)

return ReadingGoalUpdateResponse(status="ok")
2 changes: 2 additions & 0 deletions openlibrary/plugins/upstream/yearly_reading_goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from math import floor

import web
from typing_extensions import deprecated

from infogami.utils import delegate
from infogami.utils.view import public
Expand All @@ -13,6 +14,7 @@
MAX_READING_GOAL = 10_000


@deprecated("migrated to fastapi")
class yearly_reading_goal_json(delegate.page):
path = '/reading-goal'
encoding = 'json'
Expand Down
21 changes: 19 additions & 2 deletions openlibrary/utils/request_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

from contextvars import ContextVar
from dataclasses import dataclass
from urllib.parse import unquote

import web
from fastapi import Request

from infogami import config
from infogami.infobase.client import Site
from infogami.utils.delegate import create_site

Expand All @@ -36,6 +38,21 @@ class RequestContextVars:
site: ContextVar[Site] = ContextVar("site")


def setup_site(request: Request | None = None):
"""
When called from web.py, web.ctx._parsed_cookies is already set.
When called from FastAPI, we need to set it.
create_site() automatically uses the cookie to set the auth token
"""
if request:
cookie_name = config.get("login_cookie_name", "session")
cookie_value = request.cookies.get(cookie_name)
cookie_value = unquote(cookie_value) if cookie_value else ""
web.ctx._parsed_cookies = {cookie_name: cookie_value}

site.set(create_site())


def _compute_is_bot(user_agent: str | None, hhcl: str | None) -> bool:
"""Determine if the request is from a bot.

Expand Down Expand Up @@ -152,7 +169,7 @@ def set_context_from_legacy_web_py() -> None:
hhcl=web.ctx.env.get("HTTP_X_HHCL"),
)

site.set(create_site())
setup_site()
req_context.set(
RequestContextVars(
x_forwarded_for=web.ctx.env.get("HTTP_X_FORWARDED_FOR"),
Expand Down Expand Up @@ -180,7 +197,7 @@ def set_context_from_fastapi(request: Request) -> None:
hhcl=request.headers.get("X-HHCL"),
)

site.set(create_site())
setup_site(request)
req_context.set(
RequestContextVars(
x_forwarded_for=request.headers.get("X-Forwarded-For"),
Expand Down