Skip to content

Commit e6f112f

Browse files
committed
feat(api): GET /api/printers?slug=... filter
Hangar lookuped seine Printer-UUIDs lazy via slug für SSE-Subscribe. Refs strausmann/hangar#78
1 parent a3ae386 commit e6f112f

2 files changed

Lines changed: 116 additions & 5 deletions

File tree

backend/app/api/routes/printers.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from typing import Annotated, Any
2929
from uuid import UUID
3030

31-
from fastapi import APIRouter, Depends, HTTPException, status
31+
from fastapi import APIRouter, Depends, HTTPException, Query, status
3232
from sqlalchemy.ext.asyncio import AsyncSession
3333

3434
from app.auth.dependencies import AuthContext, check_printer_access
@@ -80,12 +80,28 @@ async def _get_printer_or_404(session: AsyncSession, printer_id: UUID) -> Any:
8080
summary="List all printers",
8181
description=(
8282
"Returns every registered printer. The ``paused`` flag is joined "
83-
"from ``printer_state``; it is ``false`` when no state row exists yet."
83+
"from ``printer_state``; it is ``false`` when no state row exists yet. "
84+
"Pass ``?slug=<slug>`` to filter to a single printer by exact slug match "
85+
"(returns 404 when no printer with that slug exists)."
8486
),
8587
)
86-
async def list_printers(session: SessionDep, _auth: ReadAuthDep) -> list[PrinterRead]:
87-
"""List all printers with their pause state."""
88-
printers = await printers_repo.list_all(session)
88+
async def list_printers(
89+
session: SessionDep,
90+
_auth: ReadAuthDep,
91+
slug: Annotated[str | None, Query(description="Filter by exact slug")] = None,
92+
) -> list[PrinterRead]:
93+
"""List all printers with their pause state, optionally filtered by slug."""
94+
if slug is not None:
95+
printer = await printers_repo.get_by_slug(session, slug)
96+
if printer is None:
97+
raise HTTPException(
98+
status_code=status.HTTP_404_NOT_FOUND,
99+
detail={"error_code": "printer_not_found",
100+
"error_message": f"slug={slug!r} not found"},
101+
)
102+
printers = [printer]
103+
else:
104+
printers = await printers_repo.list_all(session)
89105
result: list[PrinterRead] = []
90106
for p in printers:
91107
state = await printer_state_repo.get(session, p.id)
@@ -94,6 +110,7 @@ async def list_printers(session: SessionDep, _auth: ReadAuthDep) -> list[Printer
94110
PrinterRead(
95111
id=p.id,
96112
name=p.name,
113+
slug=p.slug,
97114
model=p.model,
98115
backend=p.backend,
99116
connection=dict(p.connection),
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""GET /api/printers?slug=... filtert auf einzelnen Printer."""
2+
from __future__ import annotations
3+
4+
from uuid import uuid4
5+
6+
import pytest
7+
import pytest_asyncio
8+
from httpx import ASGITransport, AsyncClient
9+
10+
from app.auth.dependencies import AuthContext
11+
from app.auth.scope_deps import require_admin, require_print, require_read
12+
from app.models.printer import Printer
13+
from app.repositories import printers as printers_repo
14+
15+
16+
@pytest_asyncio.fixture
17+
async def slug_client():
18+
"""AsyncClient mit gefakter Auth + korrekt gepatchter DB-Session.
19+
20+
Propagiert den _temp_db_engine-Patch (autouse, setzt eng_mod.async_session)
21+
in app.db.session (Name-Binding, wird NICHT automatisch aktualisiert).
22+
Analog zu tests/integration/api/conftest.py::api_client_with_seed.
23+
"""
24+
import app.db.engine as _eng
25+
import app.db.session as _sess
26+
from app.integrations import ( # type: ignore[attr-defined]
27+
IntegrationRegistry,
28+
_discover_plugins,
29+
)
30+
from app.main import create_app
31+
32+
# Propagate the monkeypatched engine into session.py's name binding
33+
_sess.async_session = _eng.async_session
34+
35+
if not IntegrationRegistry.names():
36+
_discover_plugins()
37+
38+
fake = AuthContext(source="api-key", scope="admin", api_key_id=uuid4(), ip="127.0.0.1")
39+
app = create_app()
40+
inner = app._app
41+
for dep in (require_read, require_print, require_admin):
42+
inner.dependency_overrides[dep] = lambda _c=fake: _c
43+
44+
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
45+
yield c
46+
47+
48+
@pytest_asyncio.fixture
49+
async def slug_db_session():
50+
"""DB-Session gegen die per-test temp-Engine (analog zu conftest.db_session)."""
51+
import app.db.engine as eng_mod
52+
53+
async with eng_mod.async_session() as s:
54+
yield s
55+
56+
57+
@pytest.fixture
58+
def read_auth_headers() -> dict:
59+
"""Leere Dict — Auth ist via dependency_overrides gefakt."""
60+
return {}
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_filter_by_slug_returns_one(slug_client: AsyncClient, slug_db_session, read_auth_headers):
65+
await printers_repo.create(slug_db_session,
66+
Printer(name="Brother PT-P750W", slug="brother-p750w",
67+
model="PT-P750W", backend="ptouch"))
68+
await printers_repo.create(slug_db_session,
69+
Printer(name="Brother QL-820NWB", slug="brother-ql820nwb",
70+
model="QL-820NWB", backend="ptouch"))
71+
72+
resp = await slug_client.get("/api/printers?slug=brother-p750w", headers=read_auth_headers)
73+
assert resp.status_code == 200
74+
body = resp.json()
75+
assert len(body) == 1
76+
assert body[0]["slug"] == "brother-p750w"
77+
78+
79+
@pytest.mark.asyncio
80+
async def test_filter_by_slug_returns_404_when_missing(slug_client, read_auth_headers):
81+
resp = await slug_client.get("/api/printers?slug=unknown", headers=read_auth_headers)
82+
assert resp.status_code == 404
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_no_filter_returns_all(slug_client: AsyncClient, slug_db_session, read_auth_headers):
87+
await printers_repo.create(slug_db_session,
88+
Printer(name="A", slug="a", model="X", backend="mock"))
89+
await printers_repo.create(slug_db_session,
90+
Printer(name="B", slug="b", model="X", backend="mock"))
91+
92+
resp = await slug_client.get("/api/printers", headers=read_auth_headers)
93+
assert resp.status_code == 200
94+
assert len(resp.json()) >= 2

0 commit comments

Comments
 (0)