diff --git a/CHANGELOG.md b/CHANGELOG.md index 40cd3dd1b..beef111fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### Added +- **A page for managing printer locations (#2962, requested and contributed by @SVAN19 in #2995)** — Printer locations used to be a free-text field in each printer's settings, so renaming a room meant editing every printer in it, and a typo quietly made a second location. The new **Printer Locations** page, opened from the button next to the sort control on the Printers page, lists every location with its printers and their status. You can create a location before any printer is in it, give it an icon and a colour, rename it, delete it, and move printers between locations one at a time or several at once. Search, sorting and hiding empty locations help on a large farm. Locations are stored by Bambuddy, so every user and every device sees the same ones. Renaming a location also updates queued jobs that target it, so a job waiting for "any X1C in Workshop" still finds its printers after Workshop becomes Garage, and so do the next runs of a batch. Locations typed with stray spaces before are tidied up on upgrade, so "Workshop " and "Workshop" become one. Each change is saved as one step, so a failure cannot leave a location half renamed, and two locations that differ only in upper and lower case are refused. Location names are limited to 100 characters, in the printer dialog too. Changing locations needs the printer edit permission; anyone who can see printers can view the page. Wiki updated. Covered by backend and frontend tests. - **A Column view in the File Manager (#3020, requested and contributed by @Thomansky in #3190)** — Next to Grid and List there is now **Column view**, as in the macOS Finder: one column per folder level, each listing that level's subfolders and then its own files, with the selected folder's files in the pane on the right. It shows where you are in the folder tree and lets you walk up and down it without going back to the sidebar. File rows carry the same actions as the list view (Print, Slice, Preview, Download, Details, Rename, Delete and the rest), and folder rows the same ⋮ menu as the sidebar. It works from the keyboard too: ↑/↓ move within a column, → or Enter go down a level, ← goes up, Enter opens a file's preview, Space ticks it, and the context-menu key or Shift+F10 moves into the row's actions. A file ticked in any column counts for the toolbar's Print, Preview, Combine, Move and Delete. With a search or tag filter active the columns step aside and the results take the full width, since they span every folder. The choice is remembered like Grid and List. Changing folders now clears the selection in every view, so Move and Delete can't act on files that are no longer on screen. The sidebar's folder ⋮ menu now closes when you click it again. - **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. An **Announcements** entry sits at the bottom of the sidebar above the System icon for everyone who may see them, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Messages past their expiry stay readable under **Earlier** for a year (up to 50); withdrawn ones disappear everywhere. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched. - **Camera snapshots reach more notifications and more providers, with an Attach Photo switch per provider (#3089, requested and contributed by @bbbenji in #3199)** — Plate Not Empty now carries a photo of the plate, taken before the chamber light is switched back off, and AI Failure Detection carries the frame the model flagged. Home Assistant (with a notify service set), Bark and Slack-format webhooks get photos too: they fetch the image themselves, so Bambuddy saves it under a random name and sends a link that opens only that one photo and stops working after 3 days. This needs **External URL** set in **Settings → Network**. Each provider has an **Attach Photo** toggle, on by default so nothing changes for existing setups, and **Test** sends a sample image when it is on. The print emails sent to the user who queued a job can show the finish photo inline when the template contains `{finish_photo_url}`. The template editor shows which events can carry a photo, and lists the variables for AI Failure Detection, Plate Not Empty, Plate Clear Required and First Layer Complete. The chamber light switched on for the plate check is now always switched back off, even when the check fails. diff --git a/backend/app/api/routes/printer_locations.py b/backend/app/api/routes/printer_locations.py new file mode 100644 index 000000000..287c59cd4 --- /dev/null +++ b/backend/app/api/routes/printer_locations.py @@ -0,0 +1,240 @@ +"""Printer locations (groups): list, create, rename, restyle, delete, assign (#2962). + +A printer's location is the free-text ``printers.location`` column, which the +scheduler's model-based targeting (``print_queue.target_location``) and the +printers filter match exactly. ``printer_locations`` adds the rest: a location +with no printers yet, and its icon and colour. The list is the union of both, +so a location typed into the printer dialog shows up here without a row. + +Every write is one transaction on the server. The page used to send one PATCH +per printer from its cached printer list: a failure halfway left a location +split under two names, and a printer another user had moved in the meantime +was moved back. +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import delete, func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.core.auth import RequirePermissionIfAuthEnabled +from backend.app.core.database import get_db +from backend.app.core.permissions import Permission +from backend.app.core.websocket import ws_manager +from backend.app.models.print_queue import PrintQueueItem +from backend.app.models.printer import Printer +from backend.app.models.printer_location import PrinterLocation +from backend.app.models.user import User +from backend.app.schemas.printer_location import ( + PrinterLocationAssign, + PrinterLocationAssignResult, + PrinterLocationCreate, + PrinterLocationDelete, + PrinterLocationDeleteResult, + PrinterLocationResponse, + PrinterLocationUpdate, +) +from backend.app.utils.natural_sort import natural_sort_key + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/printer-locations", tags=["printer-locations"]) + +DUPLICATE_NAME = "A location with this name already exists" + + +def _blank_location(): + """Printers with no location: NULL, or "" from before blanks were folded.""" + return (Printer.location.is_(None)) | (func.trim(Printer.location) == "") + + +async def _printer_counts(db: AsyncSession) -> dict[str, int]: + result = await db.execute( + select(Printer.location, func.count(Printer.id)).where(~_blank_location()).group_by(Printer.location) + ) + return dict(result.all()) + + +async def _row(db: AsyncSession, name: str) -> PrinterLocation | None: + result = await db.execute(select(PrinterLocation).where(PrinterLocation.name == name)) + return result.scalar_one_or_none() + + +async def _name_taken(db: AsyncSession, name: str, *, ignore: str | None = None) -> bool: + """Whether ``name`` is a location already, ignoring case. + + Matching elsewhere is exact, so "Workshop" and "workshop" would be two + locations that look like one. Refusing the second keeps them apart. + """ + key = name.casefold() + rows = (await db.execute(select(PrinterLocation.name))).scalars().all() + used = (await db.execute(select(Printer.location).where(~_blank_location()).distinct())).scalars().all() + return any(n.casefold() == key and n != ignore for n in (*rows, *used)) + + +async def _conflicts(db: AsyncSession, name: str) -> bool: + """Whether using ``name`` would add a case variant of a location. + + A name that already exists exactly is never a conflict, even when an older + install also holds a case variant of it: refusing would leave no way to + move a printer into, or style, a location that is plainly there. + """ + if await _row(db, name) is not None: + return False + if name in await _printer_counts(db): + return False + return await _name_taken(db, name) + + +async def _broadcast() -> None: + await ws_manager.broadcast({"type": "printer_locations_changed"}) + + +@router.get("/", response_model=list[PrinterLocationResponse]) +async def list_printer_locations( + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), +): + """Every location: those with a row, and those only printers carry.""" + counts = await _printer_counts(db) + rows = (await db.execute(select(PrinterLocation))).scalars().all() + out = { + row.name: PrinterLocationResponse( + id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0) + ) + for row in rows + } + for name, count in counts.items(): + if name not in out: + out[name] = PrinterLocationResponse(name=name, printer_count=count) + return sorted(out.values(), key=lambda loc: natural_sort_key(loc.name)) + + +@router.post("/", response_model=PrinterLocationResponse, status_code=201) +async def create_printer_location( + data: PrinterLocationCreate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), +): + """Create a location, with or without printers in it yet.""" + if await _row(db, data.name) is not None or await _conflicts(db, data.name): + raise HTTPException(status_code=409, detail=DUPLICATE_NAME) + # A location printers already use but that has no row yet gets one, which is + # how it gets an icon and colour. + row = PrinterLocation(name=data.name, icon=data.icon, color=data.color) + db.add(row) + try: + await db.commit() + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc + await db.refresh(row) + await _broadcast() + counts = await _printer_counts(db) + return PrinterLocationResponse( + id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0) + ) + + +@router.patch("/", response_model=PrinterLocationResponse) +async def update_printer_location( + data: PrinterLocationUpdate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), +): + """Rename a location and/or change its icon and colour. + + A rename moves its printers and the queue items that target it in the same + transaction, so an "any printer in " job, and the next run of a + batch, keep finding their printers. + """ + row = await _row(db, data.name) + counts = await _printer_counts(db) + if row is None and data.name not in counts: + raise HTTPException(status_code=404, detail="Location not found") + + name = data.name + new_name = data.new_name if "new_name" in data.model_fields_set else None + if new_name is not None and new_name != name: + if await _name_taken(db, new_name, ignore=name): + raise HTTPException(status_code=409, detail=DUPLICATE_NAME) + await db.execute(update(Printer).where(Printer.location == name).values(location=new_name)) + # Every row, not only pending ones: a batch clones its next run from + # its newest row whatever that row's status, so a finished run still + # pointing at the old name would send future runs nowhere. A rename is + # the same place under a new name, so history follows it too. + await db.execute( + update(PrintQueueItem).where(PrintQueueItem.target_location == name).values(target_location=new_name) + ) + name = new_name + + if row is None: + row = PrinterLocation(name=name) + db.add(row) + else: + row.name = name + if "icon" in data.model_fields_set: + row.icon = data.icon + if "color" in data.model_fields_set: + row.color = data.color + + try: + await db.commit() + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=409, detail=DUPLICATE_NAME) from exc + await db.refresh(row) + await _broadcast() + counts = await _printer_counts(db) + return PrinterLocationResponse( + id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0) + ) + + +@router.post("/delete", response_model=PrinterLocationDeleteResult) +async def delete_printer_locations( + data: PrinterLocationDelete, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), +): + """Delete locations; their printers end up with no location. + + Pending queue items that target a deleted location are left alone: changing + them to "any location" would let them start on printers they were meant to + stay off. + """ + names = set(data.names) + existing = set((await db.execute(select(PrinterLocation.name).where(PrinterLocation.name.in_(names)))).scalars()) + existing |= set(await _printer_counts(db)) & names + await db.execute(delete(PrinterLocation).where(PrinterLocation.name.in_(names))) + moved = await db.execute(update(Printer).where(Printer.location.in_(names)).values(location=None)) + await db.commit() + await _broadcast() + return PrinterLocationDeleteResult(deleted=len(existing), printers_ungrouped=moved.rowcount or 0) + + +@router.post("/assign", response_model=PrinterLocationAssignResult) +async def assign_printer_location( + data: PrinterLocationAssign, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), +): + """Move printers into a location, or out of any with null. + + By printer id, on the server: a printer someone else moved meanwhile is + moved again only if it is in this request. + """ + # Moving into a new case variant of an existing location would split it. + if data.location is not None and await _conflicts(db, data.location): + raise HTTPException(status_code=409, detail=DUPLICATE_NAME) + ids = list(dict.fromkeys(data.printer_ids)) + found = (await db.execute(select(Printer.id).where(Printer.id.in_(ids)))).scalars().all() + missing = sorted(set(ids) - set(found)) + if missing: + raise HTTPException(status_code=404, detail=f"Printer not found: {', '.join(map(str, missing))}") + result = await db.execute(update(Printer).where(Printer.id.in_(ids)).values(location=data.location)) + await db.commit() + await _broadcast() + return PrinterLocationAssignResult(moved=result.rowcount or 0) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 559a439de..f484b636a 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -349,6 +349,7 @@ async def init_db(): print_queue, printer, printer_ha_sensor, + printer_location, printer_sensor_history, project, project_bom, @@ -5184,6 +5185,9 @@ async def run_migrations(conn): # Spoolman and the location sync then imported as storage locations. await _migrate_drop_ams_slot_locations(conn) + # Data migration: printer locations as the locations API stores them (#2962). + await _migrate_normalize_printer_locations(conn) + # Migration: link a batch to the external record that asked for it (a shop # order an integration turned into prints). The unique index is what makes # a retried create safe; both columns are new, so no row can violate it. @@ -5531,6 +5535,31 @@ async def _migrate_location_ha_sensor_unique_binding(conn) -> None: ) +async def _migrate_normalize_printer_locations(conn) -> None: + """Trim printer locations and store a blank one as NULL (#2962). + + Nothing trimmed ``printers.location`` before, so "Workshop " and "Workshop" + could both be stored, and "" sat next to NULL for "no location". The + Printer Locations API trims every name it is given, so an untrimmed stored + value could never be matched to rename or delete it. Queue items' + ``target_location`` is trimmed the same way, so model-based jobs keep + matching their printers exactly. Idempotent: only rows that change are + touched. + """ + from sqlalchemy import text + + async with conn.begin_nested(): + for table, column in (("printers", "location"), ("print_queue", "target_location")): + await conn.execute( + text(f"UPDATE {table} SET {column} = NULL WHERE {column} IS NOT NULL AND TRIM({column}) = ''") # noqa: S608 # nosec B608 — fixed identifiers + ) + await conn.execute( + text( + f"UPDATE {table} SET {column} = TRIM({column}) WHERE {column} IS NOT NULL AND {column} <> TRIM({column})" + ) # noqa: S608 # nosec B608 — fixed identifiers + ) + + async def _migrate_drop_ams_slot_locations(conn) -> None: """Remove imported AMS slot markers from the storage-location catalogue. diff --git a/backend/app/main.py b/backend/app/main.py index a27241abd..52826d5ac 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -61,6 +61,7 @@ from backend.app.api.routes import ( pipeline_runs, print_log, print_queue, + printer_locations, printer_sensor_history, printers, projects, @@ -10637,6 +10638,7 @@ app.include_router(bug_report.router, prefix=app_settings.api_prefix) app.include_router(users.router, prefix=app_settings.api_prefix) app.include_router(groups.router, prefix=app_settings.api_prefix) app.include_router(printers.router, prefix=app_settings.api_prefix) +app.include_router(printer_locations.router, prefix=app_settings.api_prefix) app.include_router(archives.router, prefix=app_settings.api_prefix) app.include_router(filaments.router, prefix=app_settings.api_prefix) app.include_router(finance.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f3c8a8ff3..7845081b2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -25,6 +25,7 @@ from backend.app.models.pipeline_run import PipelineJob, PipelineRun from backend.app.models.print_batch import PrintBatch, PrintBatchPlate from backend.app.models.printer import Printer from backend.app.models.printer_ha_sensor import PrinterHASensor +from backend.app.models.printer_location import PrinterLocation from backend.app.models.printer_sensor_history import PrinterSensorHistory from backend.app.models.project import Project from backend.app.models.scheduled_drying import ScheduledDrying @@ -48,6 +49,7 @@ from backend.app.models.user_totp import UserTOTP __all__ = [ "Printer", + "PrinterLocation", "PrintArchive", "Filament", "Settings", diff --git a/backend/app/models/printer_location.py b/backend/app/models/printer_location.py new file mode 100644 index 000000000..f8a1056b1 --- /dev/null +++ b/backend/app/models/printer_location.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.core.database import Base + + +class PrinterLocation(Base): + """A printer location (group) and how it looks (#2962). + + A printer's location is still the free-text ``printers.location`` column, + which the scheduler's model-based targeting and the printers filter match + exactly. This table only adds what that column cannot hold: a location with + no printers yet (a rack or room planned before the printers move in), and + the icon and colour the Printer Locations page shows for it. A location used + by printers but missing here is still a location, with no icon or colour. + """ + + __tablename__ = "printer_locations" + + id: Mapped[int] = mapped_column(primary_key=True) + # Same width as printers.location, which holds the same value. + name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True) + # An IconPicker name ("home", "wrench"); None shows the default icon. + icon: Mapped[str | None] = mapped_column(String(50)) + # "#rrggbb"; None means no colour. + color: Mapped[str | None] = mapped_column(String(7)) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index 78bdd4319..ceea13215 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -2,6 +2,7 @@ from datetime import datetime from pydantic import BaseModel, Field, field_validator, model_validator +from backend.app.schemas.printer_location import normalize_location_name from backend.app.utils.printer_models import supports_nozzle_flow_type @@ -47,6 +48,10 @@ class PrinterCreate(PrinterBase): # connect to the printer's MQTT and bypass Bambuddy's RBAC. access_code: str = Field(..., min_length=1, max_length=20) + # Input only, not on PrinterBase: SQLite never enforced the column width, + # so a stored location may be longer, and the response must still read it. + _location = field_validator("location")(normalize_location_name) + class PlateDetectionROI(BaseModel): """Region of interest for plate detection (percentages 0.0-1.0).""" @@ -67,6 +72,7 @@ class PrinterUpdate(BaseModel): access_code: str | None = None model: str | None = None location: str | None = None + _location = field_validator("location")(normalize_location_name) is_active: bool | None = None auto_archive: bool | None = None print_hours_offset: float | None = None diff --git a/backend/app/schemas/printer_location.py b/backend/app/schemas/printer_location.py new file mode 100644 index 000000000..cb8a10fe3 --- /dev/null +++ b/backend/app/schemas/printer_location.py @@ -0,0 +1,116 @@ +"""Printer locations (groups) and their appearance (#2962).""" + +from pydantic import BaseModel, Field, field_validator + +# printers.location, print_queue.target_location and printer_locations.name are +# all VARCHAR(100). PostgreSQL refuses a longer value with an error mid-write, +# so it is refused here instead. +LOCATION_NAME_MAX_LENGTH = 100 + +_ICON_PATTERN = r"^[a-z0-9-]{1,50}$" +_COLOR_PATTERN = r"^#[0-9a-fA-F]{6}$" + + +def normalize_location_name(value: str | None) -> str | None: + """A location as stored: trimmed, and None for blank (no location). + + "" and None both meant "no location" before this existed; folding them into + one value keeps the exact-match filters from treating them as two groups. + """ + if value is None: + return None + if not isinstance(value, str): + raise ValueError("location must be a string") + value = value.strip() + if not value: + return None + if len(value) > LOCATION_NAME_MAX_LENGTH: + raise ValueError(f"location must be at most {LOCATION_NAME_MAX_LENGTH} characters") + return value + + +def _required_name(value: str) -> str: + name = normalize_location_name(value) + if name is None: + raise ValueError("location name is required") + return name + + +def _existing_name(value: str) -> str: + """The name of a location that already exists, as stored. + + Not length-checked: SQLite never enforced the column width, so a stored + location can be longer than a new one may be, and it must still be possible + to rename or delete it. + """ + if not isinstance(value, str) or not value.strip(): + raise ValueError("location name is required") + return value.strip() + + +def _blank_to_none(value: str | None) -> str | None: + if isinstance(value, str) and not value.strip(): + return None + return value + + +class PrinterLocationResponse(BaseModel): + # None for a location that only exists on printers and has no row yet. + id: int | None = None + name: str + icon: str | None = None + color: str | None = None + printer_count: int = 0 + + +class PrinterLocationCreate(BaseModel): + name: str + icon: str | None = Field(default=None, pattern=_ICON_PATTERN) + color: str | None = Field(default=None, pattern=_COLOR_PATTERN) + + _name = field_validator("name")(_required_name) + _blank = field_validator("icon", "color", mode="before")(_blank_to_none) + + +class PrinterLocationUpdate(BaseModel): + """Rename and/or restyle the location called ``name``. + + ``icon`` and ``color`` are only changed when sent; sending null or "" clears + them. + """ + + name: str + new_name: str | None = None + icon: str | None = Field(default=None, pattern=_ICON_PATTERN) + color: str | None = Field(default=None, pattern=_COLOR_PATTERN) + + _name = field_validator("name")(_existing_name) + _new_name = field_validator("new_name")(_required_name) + _blank = field_validator("icon", "color", mode="before")(_blank_to_none) + + +class PrinterLocationDelete(BaseModel): + names: list[str] = Field(min_length=1, max_length=500) + + @field_validator("names") + @classmethod + def _names(cls, values: list[str]) -> list[str]: + return list(dict.fromkeys(_existing_name(v) for v in values)) + + +class PrinterLocationDeleteResult(BaseModel): + deleted: int + printers_ungrouped: int + + +class PrinterLocationAssign(BaseModel): + """Move printers into ``location``, or out of any location with null.""" + + printer_ids: list[int] = Field(min_length=1, max_length=1000) + location: str | None = None + + _location = field_validator("location")(normalize_location_name) + + +class PrinterLocationAssignResult(BaseModel): + moved: int diff --git a/backend/tests/integration/test_printer_locations_api.py b/backend/tests/integration/test_printer_locations_api.py new file mode 100644 index 000000000..638334f86 --- /dev/null +++ b/backend/tests/integration/test_printer_locations_api.py @@ -0,0 +1,341 @@ +"""Printer locations (groups) API (#2962). + +Locations live on printers (``printers.location``) and, for appearance and +empty locations, in ``printer_locations``. Every write is one server-side +transaction: the page used to send one PATCH per printer from its cached list, +so a failure halfway split a location, and another user's move was undone. +""" + +import pytest +from httpx import AsyncClient +from sqlalchemy import inspect, select + +from backend.app.models.print_queue import PrintQueueItem +from backend.app.models.printer import Printer +from backend.app.models.printer_location import PrinterLocation + +BASE = "/api/v1/printer-locations/" + + +async def _locations(client: AsyncClient) -> dict[str, dict]: + response = await client.get(BASE) + assert response.status_code == 200 + return {loc["name"]: loc for loc in response.json()} + + +async def _printer_location(db_session, printer) -> str | None: + # A column select reads the row, not the identity map, so nothing needs + # expiring; the id comes from the identity, which never lazy-loads. + printer_id = inspect(printer).identity[0] + return (await db_session.execute(select(Printer.location).where(Printer.id == printer_id))).scalar_one() + + +@pytest.mark.asyncio +@pytest.mark.integration +class TestList: + async def test_union_of_rows_and_printer_locations(self, async_client, printer_factory, db_session): + await printer_factory(name="A", location="Workshop") + await printer_factory(name="B", location="Workshop") + await printer_factory(name="C", location="") + await printer_factory(name="D", location=None) + db_session.add(PrinterLocation(name="Future rack", icon="home", color="#3b82f6")) + await db_session.commit() + + locations = await _locations(async_client) + + assert set(locations) == {"Workshop", "Future rack"} + assert locations["Workshop"]["printer_count"] == 2 + assert locations["Workshop"]["id"] is None + assert locations["Future rack"] == { + "id": locations["Future rack"]["id"], + "name": "Future rack", + "icon": "home", + "color": "#3b82f6", + "printer_count": 0, + } + + async def test_natural_order(self, async_client, db_session): + for name in ("Rack 10", "Rack 2", "Office"): + db_session.add(PrinterLocation(name=name)) + await db_session.commit() + + response = await async_client.get(BASE) + + assert [loc["name"] for loc in response.json()] == ["Office", "Rack 2", "Rack 10"] + + +@pytest.mark.asyncio +@pytest.mark.integration +class TestCreate: + async def test_an_empty_location_persists(self, async_client): + response = await async_client.post(BASE, json={"name": " Basement ", "icon": "home", "color": "#ef4444"}) + + assert response.status_code == 201 + assert response.json()["name"] == "Basement" + assert "Basement" in await _locations(async_client) + + async def test_a_location_printers_already_use_gets_a_row(self, async_client, printer_factory): + await printer_factory(location="Workshop") + + response = await async_client.post(BASE, json={"name": "Workshop", "color": "#22c55e"}) + + assert response.status_code == 201 + assert response.json()["printer_count"] == 1 + assert response.json()["color"] == "#22c55e" + + @pytest.mark.parametrize("name", ["Basement", "basement", "BASEMENT"]) + async def test_a_duplicate_is_refused_ignoring_case(self, async_client, name): + await async_client.post(BASE, json={"name": "Basement"}) + + response = await async_client.post(BASE, json={"name": name}) + + assert response.status_code == 409 + + @pytest.mark.parametrize( + "body", + [ + {"name": ""}, + {"name": " "}, + {"name": "x" * 101}, + {"name": "Ok", "color": "red"}, + {"name": "Ok", "icon": " + - +