diff --git a/openlibrary/fastapi/lists.py b/openlibrary/fastapi/lists.py index 94adc4153fd..58adb8775a9 100644 --- a/openlibrary/fastapi/lists.py +++ b/openlibrary/fastapi/lists.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Annotated, Any, Literal -from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status from pydantic import BaseModel from infogami.infobase import client @@ -13,9 +13,12 @@ ListEditionsModel, ListSubjectsModel, SpamListError, + get_list, get_list_editions, + get_list_seeds, get_list_subjects, ) +from openlibrary.plugins.openlibrary.lists import list_seeds as _LegacyListSeeds from openlibrary.plugins.openlibrary.lists import lists_delete as _LegacyListsDelete from openlibrary.utils.request_context import site, web_ctx_ip @@ -265,8 +268,77 @@ def lists_json_post( return result -async def list_seeds(): - pass +def _get_list_seeds_or_404(key: str) -> dict: + lst = get_list_seeds(key) + if lst is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List or Series not found") + return lst + + +def _update_list_seeds(key: str, payload: dict) -> dict: + s = site.get() + lst = s.get(key) + + if lst is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List or Series not found") + + if not s.can_write(key): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied.") + + try: + # Pass the payload safely directly to the legacy processor + data = {"add": payload.get("add", []), "remove": payload.get("remove", [])} + return _LegacyListSeeds.process_seeds_update(lst, data, key) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +@router.get("/people/{username}/lists/{olid}/seeds.json") +def list_seeds_json_user(username: UsernamePath, olid: ListOLID) -> dict: + return _get_list_seeds_or_404(f"/people/{username}/lists/{olid}") + + +@router.get("/people/{username}/series/{olid}/seeds.json") +def series_seeds_json_user(username: UsernamePath, olid: ListOLID) -> dict: + return _get_list_seeds_or_404(f"/people/{username}/series/{olid}") + + +@router.get("/lists/{olid}/seeds.json") +def list_seeds_json_public(olid: ListOLID) -> dict: + return _get_list_seeds_or_404(f"/lists/{olid}") + + +@router.get("/series/{olid}/seeds.json") +def series_seeds_json_public(olid: ListOLID) -> dict: + return _get_list_seeds_or_404(f"/series/{olid}") + + +@router.post("/people/{username}/lists/{olid}/seeds.json") +def update_list_seeds_json_user( + username: UsernamePath, olid: ListOLID, payload: Annotated[dict, Body(...)], _: Annotated[AuthenticatedUser, Depends(require_authenticated_user)] +) -> dict: + return _update_list_seeds(f"/people/{username}/lists/{olid}", payload) + + +@router.post("/people/{username}/series/{olid}/seeds.json") +def update_series_seeds_json_user( + username: UsernamePath, olid: ListOLID, payload: Annotated[dict, Body(...)], _: Annotated[AuthenticatedUser, Depends(require_authenticated_user)] +) -> dict: + return _update_list_seeds(f"/people/{username}/series/{olid}", payload) + + +@router.post("/lists/{olid}/seeds.json") +def update_list_seeds_json_public( + olid: ListOLID, payload: Annotated[dict, Body(...)], _: Annotated[AuthenticatedUser, Depends(require_authenticated_user)] +) -> dict: + return _update_list_seeds(f"/lists/{olid}", payload) + + +@router.post("/series/{olid}/seeds.json") +def update_series_seeds_json_public( + olid: ListOLID, payload: Annotated[dict, Body(...)], _: Annotated[AuthenticatedUser, Depends(require_authenticated_user)] +) -> dict: + return _update_list_seeds(f"/series/{olid}", payload) class GetListEditionsParams: @@ -291,7 +363,7 @@ def _get_editions_response(key: str, params: GetListEditionsParams) -> ListEditi CommonPagination = Annotated[GetListEditionsParams, Depends()] -@router.get("/lists/{olid}/editions.json", response_model=ListEditionsModel) +@router.get("/lists/{olid}/editions.json") def list_editions_json(olid: ListOLID, params: CommonPagination) -> ListEditionsModel: """ Get paginated editions for a public list. @@ -300,7 +372,7 @@ def list_editions_json(olid: ListOLID, params: CommonPagination) -> ListEditions return _get_editions_response(key, params) -@router.get("/people/{username}/lists/{olid}/editions.json", response_model=ListEditionsModel) +@router.get("/people/{username}/lists/{olid}/editions.json") def list_editions_json_people(username: UsernamePath, olid: ListOLID, params: CommonPagination) -> ListEditionsModel: """ Get paginated editions for a specific user's list. @@ -309,7 +381,7 @@ def list_editions_json_people(username: UsernamePath, olid: ListOLID, params: Co return _get_editions_response(key, params) -@router.get("/series/{olid}/editions.json", response_model=ListEditionsModel) +@router.get("/series/{olid}/editions.json") def series_editions_json(olid: ListOLID, params: CommonPagination) -> ListEditionsModel: """ Get paginated editions for a specific series. @@ -318,7 +390,7 @@ def series_editions_json(olid: ListOLID, params: CommonPagination) -> ListEditio return _get_editions_response(key, params) -@router.get("/people/{username}/series/{olid}/editions.json", response_model=ListEditionsModel) +@router.get("/people/{username}/series/{olid}/editions.json") def series_editions_json_people(username: UsernamePath, olid: ListOLID, params: CommonPagination) -> ListEditionsModel: """ Get paginated editions for a specific user's series. @@ -330,7 +402,7 @@ def series_editions_json_people(username: UsernamePath, olid: ListOLID, params: CommonSubjectsLimit = Annotated[int, Query(ge=0, description="Number of subjects to return")] -@router.get("/people/{username}/lists/{olid}/subjects.json", response_model=ListSubjectsModel) +@router.get("/people/{username}/lists/{olid}/subjects.json") def list_subjects_json_user(username: UsernamePath, olid: ListOLID, limit: CommonSubjectsLimit = 20) -> ListSubjectsModel: key = f"/people/{username}/lists/{olid}" if data := get_list_subjects(key, limit): @@ -338,7 +410,7 @@ def list_subjects_json_user(username: UsernamePath, olid: ListOLID, limit: Commo raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List not found") -@router.get("/people/{username}/series/{olid}/subjects.json", response_model=ListSubjectsModel) +@router.get("/people/{username}/series/{olid}/subjects.json") def list_subjects_json_user_series(username: UsernamePath, olid: ListOLID, limit: CommonSubjectsLimit = 20) -> ListSubjectsModel: key = f"/people/{username}/series/{olid}" if data := get_list_subjects(key, limit): @@ -346,7 +418,7 @@ def list_subjects_json_user_series(username: UsernamePath, olid: ListOLID, limit raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List not found") -@router.get("/lists/{olid}/subjects.json", response_model=ListSubjectsModel) +@router.get("/lists/{olid}/subjects.json") def list_subjects_json_public(olid: ListOLID, limit: CommonSubjectsLimit = 20) -> ListSubjectsModel: key = f"/lists/{olid}" if data := get_list_subjects(key, limit): @@ -354,7 +426,7 @@ def list_subjects_json_public(olid: ListOLID, limit: CommonSubjectsLimit = 20) - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List not found") -@router.get("/series/{olid}/subjects.json", response_model=ListSubjectsModel) +@router.get("/series/{olid}/subjects.json") def list_subjects_json_series(olid: ListOLID, limit: CommonSubjectsLimit = 20) -> ListSubjectsModel: key = f"/series/{olid}" if data := get_list_subjects(key, limit): diff --git a/openlibrary/plugins/openlibrary/lists.py b/openlibrary/plugins/openlibrary/lists.py index 41ef3f756e2..152d5ff30b5 100644 --- a/openlibrary/plugins/openlibrary/lists.py +++ b/openlibrary/plugins/openlibrary/lists.py @@ -765,7 +765,7 @@ class list_view_yaml(list_view_json): def get_list_seeds(key): - if lst := web.ctx.site.get(key): + if lst := site.get().get(key): seeds = [seed.dict() for seed in lst.get_seeds()] return { "links": {"self": key + "/seeds", "list": key}, @@ -774,6 +774,7 @@ def get_list_seeds(key): } +@deprecated("migrated to fastapi") class list_seeds(delegate.page): path = r"((?:/people/[^/]+)?/(?:lists|series)/OL\d+L)/seeds" encoding = "json" diff --git a/openlibrary/tests/fastapi/test_list_seeds.py b/openlibrary/tests/fastapi/test_list_seeds.py new file mode 100644 index 00000000000..9abd3b14dc0 --- /dev/null +++ b/openlibrary/tests/fastapi/test_list_seeds.py @@ -0,0 +1,40 @@ +def test_get_list_seeds_public_success(fastapi_client, monkeypatch): + """Test fetching seeds for a known public list.""" + + def mock_get_list_seeds(key): + return {"entries": [{"url": "/works/OL123W", "type": "work"}], "size": 1} + + monkeypatch.setattr("openlibrary.fastapi.lists.get_list_seeds", mock_get_list_seeds) + + response = fastapi_client.get("/lists/OL3L/seeds.json") + assert response.status_code == 200 + data = response.json() + assert "entries" in data + assert data["size"] == 1 + + +def test_get_list_seeds_not_found(fastapi_client, monkeypatch): + """Test fetching seeds for a list that does not exist.""" + + def mock_get_list_seeds(key): + return None + + monkeypatch.setattr("openlibrary.fastapi.lists.get_list_seeds", mock_get_list_seeds) + + response = fastapi_client.get("/lists/OL999999L/seeds.json") + assert response.status_code == 404 + assert response.json() == {"detail": "List or Series not found"} + + +def test_post_list_seeds_unauthorized(fastapi_client, mock_site): + """Test that unauthorized users cannot mutate seeds.""" + payload = {"add": ["/works/OL123W"], "remove": []} + response = fastapi_client.post("/lists/OL3L/seeds.json", json=payload) + assert response.status_code in [401, 403] + + +def test_post_list_seeds_authorized(fastapi_client, mock_authenticated_user, mock_site): + """Test that a logged-in user successfully hits the permission logic.""" + payload = {"add": ["/works/OL123W"], "remove": []} + response = fastapi_client.post("/lists/OL3L/seeds.json", json=payload) + assert response.status_code in [200, 403, 404]