Fix the MQTT node, suggest message names, and drop Items

- MQTT nodes failed to build: the topic map was renamed to talk in ports, but
  __slots__ still declared the old name, so every MQTT node raised
  AttributeError. Building one of each node type is now a test, since __slots__
  makes this failure invisible until someone places the node.
- Port names offer the messages already in play: everything published is worth
  reading, and an input nobody provides yet is worth publishing. A message only
  connects when both ends spell it the same way, so choosing beats typing.
- Adding a port focuses its name field.
- Dragging onto an input that already reads something offers the extra port as
  well as the replacement — an MQTT or InfluxDB node usually wants both.
- The template's Item model, its routes, screens and table are gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
Melvin Strobl
2026-08-15 18:43:17 +02:00
co-authored by Claude Fable 5
parent 8c82549cf6
commit 01af7787c1
27 changed files with 365 additions and 1433 deletions
@@ -0,0 +1,40 @@
"""Drop the item table
The template's example model. Flows are persisted as files in their own git
repository, so nothing in the app uses it any more.
Revision ID: b7c41d2f8a30
Revises: fe56fa70289e
Create Date: 2026-08-15
"""
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from alembic import op
revision = "b7c41d2f8a30"
down_revision = "fe56fa70289e"
branch_labels = None
depends_on = None
def upgrade():
op.drop_table("item")
def downgrade():
op.create_table(
"item",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"title", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False
),
sa.Column(
"description", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("owner_id", sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
+1 -2
View File
@@ -1,13 +1,12 @@
from fastapi import APIRouter
from app.api.routes import flows, items, login, private, secrets, users, utils
from app.api.routes import flows, login, private, secrets, users, utils
from app.core.config import settings
api_router = APIRouter()
api_router.include_router(login.router)
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(items.router)
api_router.include_router(flows.router)
api_router.include_router(flows.ws_router)
api_router.include_router(secrets.router)
-112
View File
@@ -1,112 +0,0 @@
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException
from sqlmodel import col, func, select
from app.api.deps import CurrentUser, SessionDep
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
router = APIRouter(prefix="/items", tags=["items"])
@router.get("/", response_model=ItemsPublic)
def read_items(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve items.
"""
if current_user.is_superuser:
count_statement = select(func.count()).select_from(Item)
count = session.exec(count_statement).one()
statement = (
select(Item).order_by(col(Item.created_at).desc()).offset(skip).limit(limit)
)
items = session.exec(statement).all()
else:
count_statement = (
select(func.count())
.select_from(Item)
.where(Item.owner_id == current_user.id)
)
count = session.exec(count_statement).one()
statement = (
select(Item)
.where(Item.owner_id == current_user.id)
.order_by(col(Item.created_at).desc())
.offset(skip)
.limit(limit)
)
items = session.exec(statement).all()
return ItemsPublic(data=items, count=count)
@router.get("/{id}", response_model=ItemPublic)
def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
"""
Get item by ID.
"""
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
return item
@router.post("/", response_model=ItemPublic)
def create_item(
*, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate
) -> Any:
"""
Create new item.
"""
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
session.add(item)
session.commit()
session.refresh(item)
return item
@router.put("/{id}", response_model=ItemPublic)
def update_item(
*,
session: SessionDep,
current_user: CurrentUser,
id: uuid.UUID,
item_in: ItemUpdate,
) -> Any:
"""
Update an item.
"""
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
update_dict = item_in.model_dump(exclude_unset=True)
item.sqlmodel_update(update_dict)
session.add(item)
session.commit()
session.refresh(item)
return item
@router.delete("/{id}")
def delete_item(
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
) -> Message:
"""
Delete an item.
"""
item = session.get(Item, id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=403, detail="Not enough permissions")
session.delete(item)
session.commit()
return Message(message="Item deleted successfully")
+1 -4
View File
@@ -2,7 +2,7 @@ import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import col, delete, func, select
from sqlmodel import col, func, select
from app import crud
from app.api.deps import (
@@ -13,7 +13,6 @@ from app.api.deps import (
from app.core.config import settings
from app.core.security import get_password_hash, verify_password
from app.models import (
Item,
Message,
UpdatePassword,
User,
@@ -224,8 +223,6 @@ def delete_user(
raise HTTPException(
status_code=403, detail="Super users are not allowed to delete themselves"
)
statement = delete(Item).where(col(Item.owner_id) == user_id)
session.exec(statement)
session.delete(user)
session.commit()
return Message(message="User deleted successfully")
+1 -10
View File
@@ -1,10 +1,9 @@
import uuid
from typing import Any
from sqlmodel import Session, select
from app.core.security import get_password_hash, verify_password
from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
from app.models import User, UserCreate, UserUpdate
def create_user(*, session: Session, user_create: UserCreate) -> User:
@@ -58,11 +57,3 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
session.commit()
session.refresh(db_user)
return db_user
def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
session.add(db_item)
session.commit()
session.refresh(db_item)
return db_item
+4 -4
View File
@@ -776,7 +776,7 @@ class MqttNode(Node):
"qos",
"retain",
"keepalive",
"_topic_to_messages",
"_topic_to_ports",
"_subscription_task",
"_mqtt_client",
"_stop_event",
@@ -1001,7 +1001,7 @@ class MqttNode(Node):
logger.info(
"Started MQTT subscription for node '%s' to topics %s",
self.name,
list(self._topic_to_messages.keys()),
list(self._topic_to_ports.keys()),
)
async def stop_subscription(self) -> None:
@@ -1034,7 +1034,7 @@ class MqttNode(Node):
Background loop that listens for MQTT messages and triggers the pipeline.
Subscribes to all unique topics from the ``topics`` mapping and
uses the reverse lookup ``_topic_to_messages`` to route incoming
uses the reverse lookup ``_topic_to_ports`` to route incoming
payloads to the correct pipeline message names.
"""
import json
@@ -1052,7 +1052,7 @@ class MqttNode(Node):
keepalive=self.keepalive,
) as client:
# Subscribe to every unique topic
for topic in self._topic_to_messages:
for topic in self._topic_to_ports:
await client.subscribe(topic, qos=self.qos)
logger.info("[%s] Subscribed to %s", self.name, topic)
+1 -43
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from pydantic import EmailStr
from sqlalchemy import DateTime
from sqlmodel import Field, Relationship, SQLModel
from sqlmodel import Field, SQLModel
def get_datetime_utc() -> datetime:
@@ -53,7 +53,6 @@ class User(UserBase, table=True):
default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore
)
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
# Properties to return via API, id is always required
@@ -67,47 +66,6 @@ class UsersPublic(SQLModel):
count: int
# Shared properties
class ItemBase(SQLModel):
title: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=255)
# Properties to receive on item creation
class ItemCreate(ItemBase):
pass
# Properties to receive on item update
class ItemUpdate(ItemBase):
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
# Database model, database table inferred from class name
class Item(ItemBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
created_at: datetime | None = Field(
default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore
)
owner_id: uuid.UUID = Field(
foreign_key="user.id", nullable=False, ondelete="CASCADE"
)
owner: User | None = Relationship(back_populates="items")
# Properties to return via API, id is always required
class ItemPublic(ItemBase):
id: uuid.UUID
owner_id: uuid.UUID
created_at: datetime | None = None
class ItemsPublic(SQLModel):
data: list[ItemPublic]
count: int
# Generic message
class Message(SQLModel):
message: str
-164
View File
@@ -1,164 +0,0 @@
import uuid
from fastapi.testclient import TestClient
from sqlmodel import Session
from app.core.config import settings
from tests.utils.item import create_random_item
def test_create_item(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
data = {"title": "Foo", "description": "Fighters"}
response = client.post(
f"{settings.API_V1_STR}/items/",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == 200
content = response.json()
assert content["title"] == data["title"]
assert content["description"] == data["description"]
assert "id" in content
assert "owner_id" in content
def test_read_item(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
response = client.get(
f"{settings.API_V1_STR}/items/{item.id}",
headers=superuser_token_headers,
)
assert response.status_code == 200
content = response.json()
assert content["title"] == item.title
assert content["description"] == item.description
assert content["id"] == str(item.id)
assert content["owner_id"] == str(item.owner_id)
def test_read_item_not_found(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.get(
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == 404
content = response.json()
assert content["detail"] == "Item not found"
def test_read_item_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
response = client.get(
f"{settings.API_V1_STR}/items/{item.id}",
headers=normal_user_token_headers,
)
assert response.status_code == 403
content = response.json()
assert content["detail"] == "Not enough permissions"
def test_read_items(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
create_random_item(db)
create_random_item(db)
response = client.get(
f"{settings.API_V1_STR}/items/",
headers=superuser_token_headers,
)
assert response.status_code == 200
content = response.json()
assert len(content["data"]) >= 2
def test_update_item(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
data = {"title": "Updated title", "description": "Updated description"}
response = client.put(
f"{settings.API_V1_STR}/items/{item.id}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == 200
content = response.json()
assert content["title"] == data["title"]
assert content["description"] == data["description"]
assert content["id"] == str(item.id)
assert content["owner_id"] == str(item.owner_id)
def test_update_item_not_found(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
data = {"title": "Updated title", "description": "Updated description"}
response = client.put(
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
headers=superuser_token_headers,
json=data,
)
assert response.status_code == 404
content = response.json()
assert content["detail"] == "Item not found"
def test_update_item_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
data = {"title": "Updated title", "description": "Updated description"}
response = client.put(
f"{settings.API_V1_STR}/items/{item.id}",
headers=normal_user_token_headers,
json=data,
)
assert response.status_code == 403
content = response.json()
assert content["detail"] == "Not enough permissions"
def test_delete_item(
client: TestClient, superuser_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
response = client.delete(
f"{settings.API_V1_STR}/items/{item.id}",
headers=superuser_token_headers,
)
assert response.status_code == 200
content = response.json()
assert content["message"] == "Item deleted successfully"
def test_delete_item_not_found(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.delete(
f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
headers=superuser_token_headers,
)
assert response.status_code == 404
content = response.json()
assert content["detail"] == "Item not found"
def test_delete_item_not_enough_permissions(
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
) -> None:
item = create_random_item(db)
response = client.delete(
f"{settings.API_V1_STR}/items/{item.id}",
headers=normal_user_token_headers,
)
assert response.status_code == 403
content = response.json()
assert content["detail"] == "Not enough permissions"
+1 -3
View File
@@ -7,7 +7,7 @@ from sqlmodel import Session, delete
from app.core.config import settings
from app.core.db import engine, init_db
from app.main import app
from app.models import Item, User
from app.models import User
from tests.utils.user import authentication_token_from_email
from tests.utils.utils import get_superuser_token_headers
@@ -26,8 +26,6 @@ def db() -> Generator[Session, None, None]:
with Session(engine) as session:
init_db(session)
yield session
statement = delete(Item)
session.execute(statement)
statement = delete(User)
session.execute(statement)
session.commit()
+82
View File
@@ -0,0 +1,82 @@
"""Every node type the editor offers must actually build.
The node classes use ``__slots__``, so an attribute the class forgot to declare
only fails when someone places that node. Building one of each catches it.
"""
import pytest
from app.flow.controller import NODE_TYPES, node_type_info
from app.flow.messages import DType, MessageSpec
# Enough configuration for each type to construct; the ports decide the
# direction, so every type gets both an input and an output where it allows it.
FIXTURES: dict[str, dict] = {
"mqtt": {
"params": {"topic": "sensors/temp", "broker_host": "broker.local"},
"requires": [],
"provides": [MessageSpec(name="temp", dtype=DType.FLOAT)],
},
"http": {
"params": {"url": "/hook", "method": "POST"},
"requires": [],
"provides": [MessageSpec(name="temp", dtype=DType.FLOAT)],
},
"influxdb": {
"params": {
"url": "http://influx.local",
"token": "t",
"org": "o",
"bucket": "b",
},
"requires": [MessageSpec(name="temp", dtype=DType.FLOAT)],
"provides": [],
},
"delay": {
"params": {"interval": 5},
"requires": [MessageSpec(name="temp", dtype=DType.FLOAT)],
"provides": [MessageSpec(name="temp_out", dtype=DType.FLOAT)],
},
"mlp": {
"params": {"seed": 1},
"requires": [MessageSpec(name="temp", dtype=DType.FLOAT)],
"provides": [MessageSpec(name="score", dtype=DType.FLOAT)],
},
}
@pytest.mark.parametrize("type_name", sorted(FIXTURES))
def test_node_type_builds_and_qualifies(type_name: str):
fixture = FIXTURES[type_name]
node = NODE_TYPES[type_name].cls(
requires=fixture["requires"],
provides=fixture["provides"],
params=fixture["params"],
name=type_name,
)
node.assign_flow("house", type_name)
assert node.id == f"house.{type_name}"
for spec in [*node.input_ports, *node.output_ports]:
assert spec.name.startswith("house.")
def test_mqtt_routes_topics_by_port():
node = NODE_TYPES["mqtt"].cls(
provides=[
MessageSpec(name="temp", dtype=DType.FLOAT),
MessageSpec(name="humidity", dtype=DType.FLOAT),
],
params={"topic": {"temp": "house/temp", "humidity": "house/hum"}},
name="sensors",
)
node.assign_flow("house", "sensors")
# Qualifying the messages must not disturb the topic wiring.
assert node._topic_to_ports == {"house/temp": ["temp"], "house/hum": ["humidity"]}
def test_every_offered_type_has_a_fixture():
# A new node type without a fixture here would ship untested.
offered = {info.type for info in node_type_info() if info.type != "python"}
assert offered == set(FIXTURES)
-16
View File
@@ -1,16 +0,0 @@
from sqlmodel import Session
from app import crud
from app.models import Item, ItemCreate
from tests.utils.user import create_random_user
from tests.utils.utils import random_lower_string
def create_random_item(db: Session) -> Item:
user = create_random_user(db)
owner_id = user.id
assert owner_id is not None
title = random_lower_string()
description = random_lower_string()
item_in = ItemCreate(title=title, description=description)
return crud.create_item(session=db, item_in=item_in, owner_id=owner_id)