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
3 changes: 2 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
31 changes: 23 additions & 8 deletions frontend/src/pmp_frontend/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,21 @@

_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)."""
global _transport
_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):
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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]:
Expand All @@ -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]:
Expand All @@ -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]:
Expand Down Expand Up @@ -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]:
Expand Down
19 changes: 17 additions & 2 deletions frontend/src/pmp_frontend/repos.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/pmp_frontend/trello.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions frontend/tests/test_api_timeouts.py
Original file line number Diff line number Diff line change
@@ -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]
Loading