diff --git a/pyproject.toml b/pyproject.toml index c4f8009..57a9bd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ ignore = [ "D406", "D407", "FA100", + "FIX002", "S104", "TRY201", "TRY003", @@ -66,6 +67,8 @@ ignore = [ "DTZ003", "PLR0913", "SIM108", + "TD002", + "TD003", ] line-length = 120 target-version = "py39" diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index 9364c26..b0ccaf1 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -1,6 +1,7 @@ """Pydantic data models for typing the FastAPI request/responses.""" from datetime import datetime, timezone +from enum import Enum from typing import Optional from pydantic import BaseModel as PydanticBaseModel @@ -228,3 +229,37 @@ class BackupResponse(BaseModel): description="Path to the created backup file.", example="/home/app/.local/share/spoolman/backups/spoolman.db", ) + + +class EventType(str, Enum): + """Event types.""" + + ADDED = "added" + UPDATED = "updated" + DELETED = "deleted" + + +class Event(BaseModel): + """Event.""" + + type: EventType = Field(description="Event type.") + date: datetime = Field(description="When the event occured. UTC Timezone.") + payload: BaseModel + + +class SpoolEvent(Event): + """Event.""" + + payload: Spool = Field(description="Updated spool.") + + +class FilamentEvent(Event): + """Event.""" + + payload: Filament = Field(description="Updated filament.") + + +class VendorEvent(Event): + """Event.""" + + payload: Vendor = Field(description="Updated vendor.") diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 9611193..d3ab810 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -10,7 +10,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import contains_eager, joinedload -from spoolman.api.v1.models import Filament +from spoolman.api.v1.models import EventType, Filament, FilamentEvent from spoolman.database import models, vendor from spoolman.database.utils import ( SortOrder, @@ -45,7 +45,7 @@ async def create( if vendor_id is not None: vendor_item = await vendor.get_by_id(db, vendor_id) - db_item = models.Filament( + filament = models.Filament( name=name, registered=datetime.utcnow().replace(microsecond=0), vendor=vendor_item, @@ -61,9 +61,10 @@ async def create( settings_bed_temp=settings_bed_temp, color_hex=color_hex, ) - db.add(db_item) + db.add(filament) await db.commit() - return db_item + await filament_changed(filament, EventType.ADDED) + return filament async def get_by_id(db: AsyncSession, filament_id: int) -> models.Filament: @@ -150,7 +151,7 @@ async def update( else: setattr(filament, k, v) await db.commit() - await filament_changed(filament) + await filament_changed(filament, EventType.UPDATED) return filament @@ -160,6 +161,7 @@ async def delete(db: AsyncSession, filament_id: int) -> None: await db.delete(filament) try: await db.commit() # Flush immediately so any errors are propagated in this request. + await filament_changed(filament, EventType.DELETED) except IntegrityError as exc: await db.rollback() raise ItemDeleteError("Failed to delete filament.") from exc @@ -188,6 +190,13 @@ async def find_article_numbers( return [row[0] for row in rows.all() if row[0] is not None] -async def filament_changed(filament: models.Filament) -> None: +async def filament_changed(filament: models.Filament, typ: EventType) -> None: """Notify websocket clients that a filament has changed.""" - await websocket_manager.send(("filament", str(filament.id)), Filament.from_db(filament).json()) + await websocket_manager.send( + ("filament", str(filament.id)), + FilamentEvent( + type=typ, + date=datetime.utcnow(), + payload=Filament.from_db(filament), + ), + ) diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 7b4e543..92c5914 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -10,7 +10,7 @@ from sqlalchemy.exc import NoResultFound from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import contains_eager, joinedload -from spoolman.api.v1.models import Spool +from spoolman.api.v1.models import EventType, Spool, SpoolEvent from spoolman.database import filament, models from spoolman.database.utils import ( SortOrder, @@ -59,7 +59,7 @@ async def create( if last_used is not None: last_used = utc_timezone_naive(last_used) - db_item = models.Spool( + spool = models.Spool( filament=filament_item, registered=datetime.utcnow().replace(microsecond=0), used_weight=used_weight, @@ -70,9 +70,10 @@ async def create( comment=comment, archived=archived, ) - db.add(db_item) + db.add(spool) await db.commit() - return db_item + await spool_changed(spool, EventType.ADDED) + return spool async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool: @@ -182,7 +183,7 @@ async def update( else: setattr(spool, k, v) await db.commit() - await spool_changed(spool) + await spool_changed(spool, EventType.UPDATED) return spool @@ -190,6 +191,7 @@ async def delete(db: AsyncSession, spool_id: int) -> None: """Delete a spool object.""" spool = await get_by_id(db, spool_id) await db.delete(spool) + await spool_changed(spool, EventType.DELETED) async def use_weight_safe(db: AsyncSession, spool_id: int, weight: float) -> None: @@ -235,7 +237,7 @@ async def use_weight(db: AsyncSession, spool_id: int, weight: float) -> models.S spool.last_used = datetime.utcnow().replace(microsecond=0) await db.commit() - await spool_changed(spool) + await spool_changed(spool, EventType.UPDATED) return spool @@ -280,7 +282,7 @@ async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.S spool.last_used = datetime.utcnow().replace(microsecond=0) await db.commit() - await spool_changed(spool) + await spool_changed(spool, EventType.UPDATED) return spool @@ -304,6 +306,13 @@ async def find_lot_numbers( return [row[0] for row in rows.all() if row[0] is not None] -async def spool_changed(spool: models.Spool) -> None: +async def spool_changed(spool: models.Spool, typ: EventType) -> None: """Notify websocket clients that a spool has changed.""" - await websocket_manager.send(("spool", str(spool.id)), Spool.from_db(spool).json()) + await websocket_manager.send( + ("spool", str(spool.id)), + SpoolEvent( + type=typ, + date=datetime.utcnow(), + payload=Spool.from_db(spool), + ), + ) diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py index 6a56b5f..b420190 100644 --- a/spoolman/database/vendor.py +++ b/spoolman/database/vendor.py @@ -6,7 +6,7 @@ from typing import Optional from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from spoolman.api.v1.models import Vendor +from spoolman.api.v1.models import EventType, Vendor, VendorEvent from spoolman.database import models from spoolman.database.utils import SortOrder, add_where_clause_str from spoolman.exceptions import ItemNotFoundError @@ -20,14 +20,15 @@ async def create( comment: Optional[str] = None, ) -> models.Vendor: """Add a new vendor to the database.""" - db_item = models.Vendor( + vendor = models.Vendor( name=name, registered=datetime.utcnow().replace(microsecond=0), comment=comment, ) - db.add(db_item) + db.add(vendor) await db.commit() - return db_item + await vendor_changed(vendor, EventType.ADDED) + return vendor async def get_by_id(db: AsyncSession, vendor_id: int) -> models.Vendor: @@ -89,7 +90,7 @@ async def update( for k, v in data.items(): setattr(vendor, k, v) await db.commit() - await vendor_changed(vendor) + await vendor_changed(vendor, EventType.UPDATED) return vendor @@ -97,8 +98,16 @@ async def delete(db: AsyncSession, vendor_id: int) -> None: """Delete a vendor object.""" vendor = await get_by_id(db, vendor_id) await db.delete(vendor) + await vendor_changed(vendor, EventType.DELETED) -async def vendor_changed(vendor: models.Vendor) -> None: +async def vendor_changed(vendor: models.Vendor, typ: EventType) -> None: """Notify websocket clients that a vendor has changed.""" - await websocket_manager.send(("vendor", str(vendor.id)), Vendor.from_db(vendor).json()) + await websocket_manager.send( + ("vendor", str(vendor.id)), + VendorEvent( + type=typ, + date=datetime.utcnow(), + payload=Vendor.from_db(vendor), + ), + ) diff --git a/spoolman/ws.py b/spoolman/ws.py index d39d3e7..e6c2a56 100644 --- a/spoolman/ws.py +++ b/spoolman/ws.py @@ -4,6 +4,8 @@ import logging from fastapi import WebSocket +from spoolman.api.v1.models import Event + logger = logging.getLogger(__name__) @@ -37,12 +39,12 @@ class WebsocketManager: pool_str, ) - async def send(self, pool: tuple[str, ...], message: str) -> None: + async def send(self, pool: tuple[str, ...], evt: Event) -> None: """Send a message to all websockets in a pool.""" pool_str = ",".join(pool) if pool_str in self.connections: for websocket in self.connections[pool_str]: - await websocket.send_text(message) + await websocket.send_text(evt.json()) websocket_manager = WebsocketManager()