diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8231859..a455865 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -226,7 +226,8 @@ Newest first. "Live" means deployed to the VIVES VM by the Deploy workflow. "Mig | Changed | Footer links and address from the environment (`FOOTER_*`) | #79 | #83 | | New | Top 3 screen: add a Student to a Project outside their Top 3 | #80 | #84 | | Changed | Projects screen: GitHub ▾ / Trello ▾ menus; Status card on a Project's page | #81 | #85 | -| New | This changelog (`docs/CHANGELOG.md`) | #82 | this PR | +| New | This changelog (`docs/CHANGELOG.md`) | #82 | #86 | +| Fixed | "Create Trello boards for all Projects" said *The server cannot be reached*: the frontend waited only 5 s. The four actions for all Projects now wait up to 5 minutes and show a spinning "Working…" notification (found in the team's dummy test) | #87 | #88 | ### 2026-10-15: deadlines, meetings, daily email, footer (spec #68, #76) diff --git a/frontend/src/pmp_frontend/api.py b/frontend/src/pmp_frontend/api.py index afff6ed..5a2296f 100644 --- a/frontend/src/pmp_frontend/api.py +++ b/frontend/src/pmp_frontend/api.py @@ -10,6 +10,11 @@ _transport: httpx.AsyncBaseTransport | None = None +# Seconds to wait for the backend. The actions for all Projects call GitHub or +# Trello several times per Project, so they get much longer (#87). +TIMEOUT = 5 +BULK_TIMEOUT = 300 + def use_transport(transport: httpx.AsyncBaseTransport | None) -> None: """Send API calls through `transport` instead of the network (used by tests).""" @@ -17,9 +22,9 @@ def use_transport(transport: httpx.AsyncBaseTransport | None) -> None: _transport = transport -def _client() -> httpx.AsyncClient: +def _client(timeout: float = TIMEOUT) -> httpx.AsyncClient: base_url = "http://backend" if _transport else Settings().backend_url - return httpx.AsyncClient(base_url=base_url, transport=_transport, timeout=5) + return httpx.AsyncClient(base_url=base_url, transport=_transport, timeout=timeout) class ApiError(Exception): @@ -58,11 +63,12 @@ async def _response( json: Any = None, files: Any = None, data: Any = None, + timeout: float = TIMEOUT, ) -> httpx.Response: """The backend's answer; ApiError when it refuses or cannot be reached.""" headers = {"Authorization": f"Bearer {token}"} if token else {} try: - async with _client() as client: + async with _client(timeout) as client: response = await client.request( method, path, headers=headers, json=json, files=files, data=data ) @@ -92,9 +98,10 @@ async def _request( json: Any = None, files: Any = None, data: Any = None, + timeout: float = TIMEOUT, ) -> Any: response = await _response( - method, path, token=token, json=json, files=files, data=data + method, path, token=token, json=json, files=files, data=data, timeout=timeout ) return response.json() if response.content else None @@ -439,7 +446,9 @@ async def github_status(token: str) -> dict[str, Any]: async def create_repos(token: str) -> list[dict[str, Any]]: """Create repositories for all Projects that need one; a result per Project (project_id, title, created, message).""" - return await _request("POST", "/api/github/repos", token=token) + return await _request( + "POST", "/api/github/repos", token=token, timeout=BULK_TIMEOUT + ) async def trello_status(token: str) -> dict[str, Any]: @@ -450,7 +459,9 @@ async def trello_status(token: str) -> dict[str, Any]: async def create_trello_boards(token: str) -> list[dict[str, Any]]: """Create a Workspace and board for all Projects that need one; a result per Project (project_id, title, created, message).""" - return await _request("POST", "/api/trello/boards", token=token) + return await _request( + "POST", "/api/trello/boards", token=token, timeout=BULK_TIMEOUT + ) async def check_trello_members(token: str, project_id: int) -> dict[str, Any]: @@ -463,7 +474,9 @@ async def check_trello_members(token: str, project_id: int) -> dict[str, Any]: async def check_all_trello_members(token: str) -> list[dict[str, Any]]: """Check every Active Project's board: project_id, title, message.""" - return await _request("POST", "/api/trello/check-all", token=token) + return await _request( + "POST", "/api/trello/check-all", token=token, timeout=BULK_TIMEOUT + ) async def board_access(token: str, project_id: int) -> dict[str, Any]: @@ -589,7 +602,9 @@ async def check_members(token: str, project_id: int) -> dict[str, Any]: async def check_all_members(token: str) -> list[dict[str, Any]]: """Check every Active Project's repository: project_id, title, message.""" - return await _request("POST", "/api/github/check-all", token=token) + return await _request( + "POST", "/api/github/check-all", token=token, timeout=BULK_TIMEOUT + ) async def my_repository(token: str) -> dict[str, Any]: diff --git a/frontend/src/pmp_frontend/repos.py b/frontend/src/pmp_frontend/repos.py index d3ad5de..b6692b6 100644 --- a/frontend/src/pmp_frontend/repos.py +++ b/frontend/src/pmp_frontend/repos.py @@ -1,6 +1,7 @@ """Project repositories on GitHub (spec #49): the repository card on a Project's page, and "Create repos for all Projects" / "Check all members".""" +from collections.abc import Awaitable from typing import Any from nicegui import ui @@ -87,6 +88,16 @@ async def access() -> None: ) +async def while_working[T](text: str, call: Awaitable[T]) -> T: + """Await `call` with a spinning notification: the actions for all Projects + can take a minute or more (#87).""" + working = ui.notification(text, spinner=True, timeout=None) + try: + return await call + finally: + working.dismiss() + + def show_results(title: str, lines: list[tuple[str, bool]]) -> None: """`lines`: (text, whether it went well).""" with ui.dialog() as dialog, ui.card().classes("w-full max-w-lg"): @@ -125,7 +136,9 @@ async def create() -> None: if not await confirm(question, "Create"): return try: - results = await api.create_repos(token()) + results = await while_working( + "Creating repositories…", api.create_repos(token()) + ) except api.ApiError as problem: ui.notify(problem.message, type="negative") return @@ -151,7 +164,9 @@ async def check_all() -> None: if not await confirm(question, "Check"): return try: - results = await api.check_all_members(token()) + results = await while_working( + "Checking members…", api.check_all_members(token()) + ) except api.ApiError as problem: ui.notify(problem.message, type="negative") return diff --git a/frontend/src/pmp_frontend/trello.py b/frontend/src/pmp_frontend/trello.py index 163d7a4..453ae5a 100644 --- a/frontend/src/pmp_frontend/trello.py +++ b/frontend/src/pmp_frontend/trello.py @@ -7,7 +7,7 @@ from nicegui import ui from pmp_frontend import api -from pmp_frontend.repos import show_results +from pmp_frontend.repos import show_results, while_working from pmp_frontend.shell import ErrorMessage, confirm, token SIGN_UP_URL = "https://trello.com/signup" @@ -98,7 +98,9 @@ async def create() -> None: if not await confirm(question, "Create"): return try: - results = await api.create_trello_boards(token()) + results = await while_working( + "Creating Trello boards…", api.create_trello_boards(token()) + ) except api.ApiError as problem: ui.notify(problem.message, type="negative") return @@ -124,7 +126,9 @@ async def check_all() -> None: if not await confirm(question, "Check"): return try: - results = await api.check_all_trello_members(token()) + results = await while_working( + "Checking Trello members…", api.check_all_trello_members(token()) + ) except api.ApiError as problem: ui.notify(problem.message, type="negative") return diff --git a/frontend/tests/test_api_timeouts.py b/frontend/tests/test_api_timeouts.py new file mode 100644 index 0000000..1627799 --- /dev/null +++ b/frontend/tests/test_api_timeouts.py @@ -0,0 +1,42 @@ +import httpx +import pytest + +from pmp_frontend import api + + +@pytest.fixture +def timeouts(monkeypatch: pytest.MonkeyPatch) -> list[float]: + """The timeout of every API client the frontend opens.""" + seen: list[float] = [] + real_client = api._client + + def spying_client(timeout: float = api.TIMEOUT) -> httpx.AsyncClient: + seen.append(timeout) + return real_client(timeout) + + monkeypatch.setattr(api, "_client", spying_client) + return seen + + +@pytest.mark.parametrize( + "bulk_action", + [ + api.create_repos, + api.check_all_members, + api.create_trello_boards, + api.check_all_trello_members, + ], +) +async def test_actions_for_all_projects_wait_longer(timeouts, bulk_action): + # Without a login the backend refuses; only the wait matters here. + with pytest.raises(api.ApiError): + await bulk_action("no-token") + + assert timeouts == [api.BULK_TIMEOUT] + + +async def test_other_calls_keep_the_short_wait(timeouts): + with pytest.raises(api.ApiError): + await api.github_status("no-token") + + assert timeouts == [api.TIMEOUT]