688 lines
23 KiB
Python
688 lines
23 KiB
Python
"""Planning a rename and its document part (sections 1.5 and 5.1 of
|
|
docs/private/id_refac_rename.md, "The key rename operation" in id_refac.md)."""
|
|
|
|
import ast
|
|
import inspect
|
|
import json
|
|
import time
|
|
from collections.abc import Iterator
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import JSON, String
|
|
from sqlmodel import Session, SQLModel, delete
|
|
from sqlmodel.sql.sqltypes import AutoString
|
|
|
|
from fluksio.core.config import settings
|
|
from fluksio.core.db import engine
|
|
from fluksio.flow.alerts import AlertsConfig, Channel
|
|
from fluksio.flow.controller import FlowController
|
|
from fluksio.flow.dashboards import (
|
|
DASHBOARD_BINDINGS,
|
|
WIDGET_BINDINGS,
|
|
DashboardDef,
|
|
DashboardStore,
|
|
SettingDef,
|
|
WidgetDef,
|
|
)
|
|
from fluksio.flow.executor import ExecutionService
|
|
from fluksio.flow.messages import MessageSpec
|
|
from fluksio.flow.namespace import FLOW_FIELDS, Rename, names
|
|
from fluksio.flow.queue import MemoryWorkQueue, WorkItem
|
|
from fluksio.flow.rename import SQL_NAMES, RenamePlan, plan_rename, rewrite_documents
|
|
from fluksio.flow.schemas import FlowDef, FlowInput, FlowOrigin, NodeDef
|
|
from fluksio.flow.state import MemoryState
|
|
from fluksio.flow.store import FlowStore
|
|
from fluksio.models import AlertSent, Run, RunArtifact, RunMetric, RunNode
|
|
from tests.utils.stores import created
|
|
|
|
NOW = datetime.now(UTC)
|
|
|
|
|
|
def _flow(name: str, *nodes: NodeDef, **fields) -> FlowDef:
|
|
return FlowDef(name=name, nodes=list(nodes), **fields)
|
|
|
|
|
|
def _reads(node_id: str, *messages: str) -> NodeDef:
|
|
return NodeDef(id=node_id, requires=[MessageSpec(name=m) for m in messages])
|
|
|
|
|
|
def _writes(node_id: str, *messages: str) -> NodeDef:
|
|
return NodeDef(id=node_id, provides=[MessageSpec(name=m) for m in messages])
|
|
|
|
|
|
@pytest.fixture
|
|
def world(tmp_path: Path) -> Iterator[SimpleNamespace]:
|
|
"""`heating`, published with a draft over it; `lights` reading it, published;
|
|
`notes` reading it, only a draft; `heating_extra`, which only looks alike;
|
|
and `home`, a dashboard bound to it, published with a draft over it."""
|
|
store = FlowStore(tmp_path / "flows")
|
|
dashboards = DashboardStore(store)
|
|
queue = MemoryWorkQueue()
|
|
controller = FlowController(
|
|
store, state=MemoryState(), execution=ExecutionService(queue)
|
|
)
|
|
|
|
heating = _flow(
|
|
"heating",
|
|
_writes("sensor", "temp", "heating.setpoint"),
|
|
inputs=[FlowInput(spec=MessageSpec(name="heating.target"))],
|
|
outputs=["temp", "heating.setpoint"],
|
|
)
|
|
store.write_flow(heating)
|
|
draft = store.read_flow("heating", draft=True)
|
|
draft.nodes.append(_reads("valve", "heating.temp"))
|
|
store.write_draft(draft, draft.version)
|
|
store.write_flow(_flow("lights", _reads("lamp", "heating.temp", "lights.level")))
|
|
created(store, _flow("notes", _reads("note", "heating.mode")))
|
|
store.write_flow(_flow("heating_extra", _writes("x", "heating_extra.x")))
|
|
|
|
home = DashboardDef(
|
|
name="home",
|
|
settings={"theme": SettingDef(message="heating.mode", dtype="str")},
|
|
widgets=[
|
|
WidgetDef(
|
|
id="t",
|
|
type="stat",
|
|
title="heating.temp",
|
|
config={"message": "heating.temp"},
|
|
),
|
|
WidgetDef(
|
|
id="s",
|
|
type="slider",
|
|
config={
|
|
"target": "heating.setpoint",
|
|
"reading": {"message": "heating.temp", "label": "heating.temp"},
|
|
},
|
|
),
|
|
WidgetDef(id="n", type="markdown", config={"text": "heating.temp"}),
|
|
],
|
|
)
|
|
dashboards.write(home)
|
|
current = dashboards.read("home", draft=True)
|
|
dashboards.write_draft(
|
|
current.model_copy(update={"title": "Home"}), current.version
|
|
)
|
|
dashboards.write(DashboardDef(name="away"))
|
|
|
|
yield SimpleNamespace(
|
|
store=store, dashboards=dashboards, controller=controller, queue=queue
|
|
)
|
|
|
|
|
|
def _plan(
|
|
world, new: str = "warmth", kind: str = "flow", obj: str = "heating", alerts=None
|
|
) -> RenamePlan:
|
|
return plan_rename(
|
|
world.controller, world.dashboards, kind, obj, new, alerts or AlertsConfig()
|
|
)
|
|
|
|
|
|
def _codes(found) -> set[str]:
|
|
return {one.code for one in found}
|
|
|
|
|
|
def _files(root: Path) -> dict[str, bytes]:
|
|
return {
|
|
str(path.relative_to(root)): path.read_bytes()
|
|
for path in sorted(root.rglob("*"))
|
|
if path.is_file() and ".git" not in path.parts
|
|
}
|
|
|
|
|
|
def _apply(world, rewrites) -> None:
|
|
for rewrite in rewrites:
|
|
for path, _, after in rewrite.files:
|
|
(world.store.root / path).write_text(after)
|
|
|
|
|
|
def test_a_plan_lists_the_flow_what_names_it_and_what_moves(world):
|
|
world.controller.state.set("heating.temp", 20.5)
|
|
world.controller.state.set("__version__:heating.temp", 3)
|
|
world.queue.add(
|
|
WorkItem(
|
|
kind="cascade",
|
|
node="lights.lamp",
|
|
flow="lights",
|
|
outputs={"heating.temp": 1},
|
|
)
|
|
)
|
|
alerts = AlertsConfig(
|
|
channels=[
|
|
Channel(
|
|
name="board", kind="dashboard", config={"message": "heating.alerts"}
|
|
),
|
|
Channel(name="mail", kind="smtp", config={"to": "heating.x@example.com"}),
|
|
]
|
|
)
|
|
with Session(engine) as session:
|
|
session.add(Run(id="r-plan", created_at=NOW, flow_id="heating", status="ok"))
|
|
session.add(RunMetric(run_id="r-plan", name="heating.loss", step=0))
|
|
session.add(
|
|
RunArtifact(
|
|
run_id="r-plan",
|
|
name="heating.plot",
|
|
digest="d",
|
|
size=1,
|
|
media_type="image/png",
|
|
)
|
|
)
|
|
session.add(
|
|
RunNode(
|
|
run_id="r-plan", node="heating.sensor", outputs='{"heating.temp": 1}'
|
|
)
|
|
)
|
|
session.add(AlertSent(key="node_failed:heating.sensor", ts=time.time()))
|
|
session.add(AlertSent(key="node_failed:heating_extra.x", ts=time.time()))
|
|
session.commit()
|
|
try:
|
|
plan = _plan(world, alerts=alerts)
|
|
finally:
|
|
with Session(engine) as session:
|
|
for model in (RunMetric, RunArtifact, RunNode, Run, AlertSent):
|
|
session.execute(delete(model))
|
|
session.commit()
|
|
|
|
assert plan.allowed, plan.refusals
|
|
assert (plan.name, plan.new_name, plan.version) == ("heating", "warmth", 2)
|
|
listed = {
|
|
(d.kind, d.id): (d.published_version, d.draft_version, d.fields)
|
|
for d in plan.documents
|
|
}
|
|
assert listed == {
|
|
# Its own qualified names: three in the published variant, four in
|
|
# the draft.
|
|
("flow", "heating"): (1, 2, 4),
|
|
("flow", "lights"): (1, None, 1),
|
|
("flow", "notes"): (None, 1, 1),
|
|
("dashboard", "home"): (1, 2, 4),
|
|
}
|
|
assert plan.fingerprint.startswith("sha256:")
|
|
assert plan.history is not None and plan.history.model_dump() == {
|
|
"runs": 1,
|
|
"metric_rows": 1,
|
|
"artifact_rows": 1,
|
|
"output_rows": 1,
|
|
}
|
|
assert plan.alerts is not None and plan.alerts.model_dump() == {
|
|
"channels": 1,
|
|
"ledger_rows": 1,
|
|
}
|
|
assert plan.runtime is not None
|
|
assert (
|
|
plan.runtime.durable,
|
|
plan.runtime.state_keys,
|
|
plan.runtime.queue_items,
|
|
) == (False, 2, 1)
|
|
assert _codes(plan.warnings) == {
|
|
"external_references",
|
|
"drafts_rewritten",
|
|
"not_durable",
|
|
"engine_pause",
|
|
}
|
|
|
|
|
|
def test_a_dry_run_changes_no_bytes(world):
|
|
before = _files(world.store.root)
|
|
head = world.store._git("rev-parse", "HEAD").stdout
|
|
|
|
_plan(world)
|
|
_plan(world, "house", "dashboard", "home")
|
|
|
|
assert _files(world.store.root) == before
|
|
assert world.store._git("rev-parse", "HEAD").stdout == head
|
|
|
|
|
|
def test_the_fingerprint_follows_every_listed_documents_version(world):
|
|
first = _plan(world)
|
|
assert _plan(world).fingerprint == first.fingerprint
|
|
|
|
lights = world.store.read_flow("lights")
|
|
world.store.write_draft(
|
|
lights.model_copy(update={"title": "Lights"}), lights.version
|
|
)
|
|
|
|
assert _plan(world).fingerprint != first.fingerprint
|
|
|
|
|
|
def test_a_rename_rewrites_bindings_and_nothing_else(world):
|
|
legacy = world.dashboards.root / "legacy" / "dashboard.json"
|
|
legacy.parent.mkdir()
|
|
legacy.write_text(
|
|
json.dumps(
|
|
{
|
|
"name": "legacy",
|
|
"version": 4,
|
|
"pages": [
|
|
{
|
|
"sections": [
|
|
{
|
|
"widgets": [
|
|
{
|
|
"id": "w",
|
|
"type": "stat",
|
|
"config": {"message": "heating.temp"},
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
],
|
|
},
|
|
indent=2,
|
|
)
|
|
)
|
|
device = world.store.root / "_devices" / "panel" / "device.json"
|
|
device.parent.mkdir(parents=True)
|
|
device.write_text('{"name": "panel", "dashboards": ["home", "legacy"]}\n')
|
|
before = _files(world.store.root)
|
|
|
|
rewrites = rewrite_documents(
|
|
world.store, world.dashboards, "flow", "heating", Rename("heating", "warmth")
|
|
)
|
|
_apply(world, rewrites)
|
|
|
|
changed = {path for r in rewrites for path, _, _ in r.files}
|
|
after = _files(world.store.root)
|
|
# The stability set: no file appears or goes, and nothing else changes.
|
|
assert after.keys() == before.keys()
|
|
assert {path for path in after if after[path] != before[path]} == changed
|
|
assert "_devices/panel/device.json" not in changed
|
|
for kind, stem, paths in (
|
|
("flow", "*/flow*.json", FLOW_FIELDS),
|
|
("dashboard", "_dashboards/*/dashboard*.json", DASHBOARD_BINDINGS),
|
|
):
|
|
for path in world.store.root.glob(stem):
|
|
data = json.loads(path.read_text())
|
|
assert data.get("id", path.parent.name) == path.parent.name
|
|
bound = names(data, paths)
|
|
assert not [n for n in bound if n.startswith("heating.")], (kind, path)
|
|
assert (
|
|
world.store.read_flow("heating_extra").nodes[0].provides[0].name
|
|
== "heating_extra.x"
|
|
)
|
|
|
|
renamed = world.store.read_flow("heating", draft=True)
|
|
assert renamed.name == "warmth" and renamed.version == 3
|
|
assert [s.name for s in renamed.nodes[0].provides] == ["temp", "warmth.setpoint"]
|
|
assert renamed.inputs[0].spec.name == "warmth.target"
|
|
assert renamed.outputs == ["temp", "warmth.setpoint"]
|
|
assert world.store.read_flow("heating").version == 3
|
|
assert (
|
|
world.store.read_flow("notes", draft=True).nodes[0].requires[0].name
|
|
== "warmth.mode"
|
|
)
|
|
|
|
home = world.dashboards.read("home", draft=True)
|
|
assert (home.version, home.title) == (3, "Home")
|
|
assert home.settings["theme"].message == "warmth.mode"
|
|
assert home.widgets[0].messages == ["warmth.temp"]
|
|
# Labels and free text are the person's.
|
|
assert home.widgets[0].title == "heating.temp"
|
|
assert home.widgets[1].target == "warmth.setpoint"
|
|
assert home.widgets[1].reading == {
|
|
"message": "warmth.temp",
|
|
"label": "heating.temp",
|
|
}
|
|
assert home.widgets[2].config == {"text": "heating.temp"}
|
|
assert world.dashboards.read("home").version == 3
|
|
assert json.loads(legacy.read_text())["pages"][0]["sections"][0]["widgets"][0][
|
|
"config"
|
|
] == {"message": "warmth.temp"}
|
|
|
|
|
|
def test_renaming_back_restores_the_documents_but_for_versions(world):
|
|
def unversioned() -> dict:
|
|
return {
|
|
path: {k: v for k, v in json.loads(text).items() if k != "version"}
|
|
for path, text in _files(world.store.root).items()
|
|
if path.endswith(".json") and not path.endswith("runtime.json")
|
|
}
|
|
|
|
before = unversioned()
|
|
_apply(
|
|
world,
|
|
rewrite_documents(
|
|
world.store,
|
|
world.dashboards,
|
|
"flow",
|
|
"heating",
|
|
Rename("heating", "warmth"),
|
|
),
|
|
)
|
|
assert unversioned() != before
|
|
_apply(
|
|
world,
|
|
rewrite_documents(
|
|
world.store,
|
|
world.dashboards,
|
|
"flow",
|
|
"heating",
|
|
Rename("warmth", "heating"),
|
|
),
|
|
)
|
|
assert unversioned() == before
|
|
|
|
|
|
def test_a_dashboard_rename_changes_its_key_and_versions_only(world):
|
|
plan = _plan(world, "house", "dashboard", "home")
|
|
assert plan.allowed and plan.version == 2
|
|
assert [
|
|
(d.kind, d.id, d.published_version, d.draft_version, d.fields)
|
|
for d in plan.documents
|
|
] == [("dashboard", "home", 1, 2, 0)]
|
|
assert (plan.alerts, plan.history, plan.runtime, plan.warnings) == (
|
|
None,
|
|
None,
|
|
None,
|
|
[],
|
|
)
|
|
assert _codes(_plan(world, "away", "dashboard", "home").refusals) == {"name_taken"}
|
|
|
|
(rewrite,) = rewrite_documents(
|
|
world.store, world.dashboards, "dashboard", "home", Rename("home", "house")
|
|
)
|
|
_apply(world, [rewrite])
|
|
published, draft = (
|
|
world.dashboards.read("home"),
|
|
world.dashboards.read("home", draft=True),
|
|
)
|
|
assert (published.name, published.version, draft.name, draft.version) == (
|
|
"house",
|
|
3,
|
|
"house",
|
|
3,
|
|
)
|
|
assert draft.widgets[0].messages == ["heating.temp"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("new", "code"),
|
|
[
|
|
("Warmth", "invalid_name"),
|
|
("f_abcdefgh", "reserved_shape"),
|
|
("heating", "same_name"),
|
|
("lights", "name_taken"),
|
|
("notes", "name_taken"),
|
|
],
|
|
)
|
|
def test_a_key_that_cannot_be_taken_is_refused(world, new, code):
|
|
plan = _plan(world, new)
|
|
assert not plan.allowed
|
|
assert code in _codes(plan.refusals)
|
|
|
|
|
|
def test_code_defined_flows_are_refused(world):
|
|
lights = world.store.read_flow("lights")
|
|
world.store.write_flow(lights.model_copy(update={"origin": FlowOrigin()}))
|
|
plan = _plan(world)
|
|
assert [(r.code, r.ids) for r in plan.refusals] == [
|
|
("code_defined_dependent", ["lights"])
|
|
]
|
|
|
|
heating = world.store.read_flow("heating")
|
|
world.store.write_flow(heating.model_copy(update={"origin": FlowOrigin()}))
|
|
assert _codes(_plan(world).refusals) == {"code_defined", "code_defined_dependent"}
|
|
|
|
|
|
def test_an_active_run_of_an_affected_flow_is_refused(world):
|
|
with Session(engine) as session:
|
|
session.add(
|
|
Run(id="r-busy", created_at=NOW, flow_id="lights", status="running")
|
|
)
|
|
session.add(
|
|
Run(
|
|
id="r-train",
|
|
created_at=NOW,
|
|
flow_id="trainer",
|
|
for_node="heating.model",
|
|
status="queued",
|
|
)
|
|
)
|
|
session.add(
|
|
Run(id="r-other", created_at=NOW, flow_id="heating_extra", status="running")
|
|
)
|
|
session.commit()
|
|
try:
|
|
plan = _plan(world)
|
|
finally:
|
|
with Session(engine) as session:
|
|
session.execute(delete(Run))
|
|
session.commit()
|
|
assert [(r.code, sorted(r.ids)) for r in plan.refusals] == [
|
|
("run_active", ["r-busy", "r-train"])
|
|
]
|
|
|
|
|
|
def test_what_still_lives_under_the_new_key_is_refused(world):
|
|
state = world.controller.state
|
|
# A node's own key is its data, even when it reads like a name.
|
|
state.set("__node__:lights.lamp:last:warmth.temp", 1)
|
|
assert _plan(world).allowed
|
|
|
|
state.set("__last_seen__:lights.lamp:warmth.temp", 1)
|
|
world.queue.add(WorkItem(kind="cascade", node="warmth.sensor", flow="warmth"))
|
|
plan = _plan(world)
|
|
assert [r.code for r in plan.refusals] == ["target_in_use"]
|
|
assert "__last_seen__:lights.lamp:warmth.temp" in plan.refusals[0].detail
|
|
assert world.controller.key_residue("warmth") == [
|
|
"__last_seen__:lights.lamp:warmth.temp",
|
|
"queued work of 'warmth.sensor'",
|
|
]
|
|
|
|
state.delete("__last_seen__:lights.lamp:warmth.temp")
|
|
world.queue.clear_flow("warmth")
|
|
away = world.dashboards.read("away")
|
|
world.dashboards.write(
|
|
away.model_copy(
|
|
update={
|
|
"widgets": [
|
|
WidgetDef(id="w", type="stat", config={"message": "warmth.temp"})
|
|
]
|
|
}
|
|
)
|
|
)
|
|
plan = _plan(world)
|
|
assert [(r.code, r.ids) for r in plan.refusals] == [("target_in_use", ["away"])]
|
|
|
|
|
|
def test_history_that_would_collide_or_overflow_is_refused(world):
|
|
long = "heating." + "x" * (128 - len("heating."))
|
|
with Session(engine) as session:
|
|
session.add(Run(id="r-hist", created_at=NOW, flow_id="heating", status="ok"))
|
|
session.add(RunMetric(run_id="r-hist", name="heating_v2.loss", step=0))
|
|
session.add(RunMetric(run_id="r-hist", name=long, step=0))
|
|
session.commit()
|
|
try:
|
|
assert _codes(_plan(world, "heating_v2").refusals) == {
|
|
"history_collision",
|
|
"name_too_long",
|
|
}
|
|
# A key no longer than the old one fits.
|
|
assert _codes(_plan(world, "warmth").refusals) == set()
|
|
finally:
|
|
with Session(engine) as session:
|
|
for model in (RunMetric, Run):
|
|
session.execute(delete(model))
|
|
session.commit()
|
|
|
|
|
|
def test_another_engine_or_an_old_redis_is_refused(world, monkeypatch):
|
|
monkeypatch.setattr(
|
|
MemoryWorkQueue, "other_consumers", lambda self, ms: ["engine-b"]
|
|
)
|
|
monkeypatch.setattr(MemoryWorkQueue, "server_version", lambda self: (6, 2))
|
|
assert _codes(_plan(world).refusals) == {"other_engine", "redis_too_old"}
|
|
|
|
|
|
def test_what_a_rename_leaves_behind_is_said(world):
|
|
# A flow whose id is the new key, under a key of its own.
|
|
world.store.create(_flow("elsewhere"), "warmth")
|
|
world.dashboards.write(DashboardDef(name="heating_results"))
|
|
world.queue.park(
|
|
"heating", WorkItem(kind="cascade", node="heating.sensor", flow="heating")
|
|
)
|
|
|
|
plan = _plan(world)
|
|
assert plan.allowed
|
|
assert {"new_name_is_id", "results_dashboard", "paused"} <= _codes(plan.warnings)
|
|
|
|
|
|
def test_every_binding_a_widget_reads_is_in_the_key_table():
|
|
"""A new binding in an accessor fails here until the table knows it, so a
|
|
rename cannot leave it behind. Keys that hold no message name are listed
|
|
as such."""
|
|
plain = {
|
|
"source",
|
|
"dtype",
|
|
"inner_dtype",
|
|
"request_dtype",
|
|
"history",
|
|
"points",
|
|
"format",
|
|
"counters",
|
|
"ids",
|
|
"group",
|
|
"flow",
|
|
"latest",
|
|
}
|
|
tree = ast.parse(inspect.getsource(WidgetDef))
|
|
read = {
|
|
call.args[0].value
|
|
for call in ast.walk(tree)
|
|
if isinstance(call, ast.Call)
|
|
and isinstance(call.func, ast.Attribute)
|
|
and call.func.attr == "get"
|
|
and call.args
|
|
and isinstance(call.args[0], ast.Constant)
|
|
}
|
|
bound = {part for path in WIDGET_BINDINGS for part in path if part != "*"}
|
|
assert read - plain == bound
|
|
assert set(SettingDef.model_fields) - {"value", "dtype"} == {"message"}
|
|
assert ("settings", "*", "message") in DASHBOARD_BINDINGS
|
|
|
|
|
|
def test_every_history_column_is_classified():
|
|
"""A column added to the history fails here until it says whether it
|
|
names a message, which a rename then rewrites (`SQL_NAMES`), or not."""
|
|
unnamed = {
|
|
# Ids, which never change: flows and nodes are filed by id.
|
|
"run": {
|
|
"id",
|
|
"flow",
|
|
"commit",
|
|
"origin_commit",
|
|
"code_digest",
|
|
"params",
|
|
"passed",
|
|
"declared",
|
|
"params_digest",
|
|
"group_id",
|
|
"parent_id",
|
|
"idempotency_key",
|
|
"external_id",
|
|
"cause",
|
|
"for_node",
|
|
"status",
|
|
"status_reason",
|
|
"labels",
|
|
"needs",
|
|
"result",
|
|
"engine",
|
|
"actor",
|
|
},
|
|
"run_node": {
|
|
"run_id",
|
|
"node",
|
|
"status",
|
|
"worker",
|
|
"error",
|
|
"logs",
|
|
"cache_key",
|
|
"cached_from",
|
|
},
|
|
"run_metric": {"run_id", "node"},
|
|
"run_artifact": {"run_id", "digest", "media_type", "filename", "node"},
|
|
"flow_run": {"id", "flow", "source", "parent", "status"},
|
|
"metric_minute": {"flow", "node"},
|
|
"engine_event": {"type", "flow", "node", "detail", "actor", "run"},
|
|
"flow_tombstone": {"id", "name"},
|
|
}
|
|
# Tables that hold no flow, node or message at all.
|
|
unrelated = {
|
|
"user",
|
|
"oauth_client",
|
|
"oauth_authorization_code",
|
|
"oauth_refresh_token",
|
|
"flavor",
|
|
"worker_token",
|
|
"connector_state",
|
|
"alembic_version",
|
|
}
|
|
found: dict[str, set[str]] = {}
|
|
for table in SQLModel.metadata.sorted_tables:
|
|
if table.name in unrelated:
|
|
continue
|
|
found[table.name] = {
|
|
column.name
|
|
for column in table.columns
|
|
if isinstance(column.type, String | AutoString | JSON)
|
|
}
|
|
classified = dict(unnamed)
|
|
for table, column in SQL_NAMES:
|
|
classified.setdefault(table, set()).add(column)
|
|
assert found == classified
|
|
|
|
|
|
def test_planning_is_a_superusers(
|
|
client: TestClient, superuser_token_headers, normal_user_token_headers
|
|
):
|
|
flows = f"{settings.API_V1_STR}/flows"
|
|
made = client.post(
|
|
f"{flows}/", headers=superuser_token_headers, json={"name": "plan_me"}
|
|
)
|
|
assert made.status_code == 201, made.text
|
|
flow_id = made.json()["definition"]["id"]
|
|
try:
|
|
body = {"new_name": "plan_me_too"}
|
|
assert (
|
|
client.post(
|
|
f"{flows}/{flow_id}/rename/plan",
|
|
headers=normal_user_token_headers,
|
|
json=body,
|
|
).status_code
|
|
== 403
|
|
)
|
|
assert (
|
|
client.post(
|
|
f"{flows}/f_nothere0/rename/plan",
|
|
headers=superuser_token_headers,
|
|
json=body,
|
|
).status_code
|
|
== 404
|
|
)
|
|
plan = client.post(
|
|
f"{flows}/{flow_id}/rename/plan", headers=superuser_token_headers, json=body
|
|
)
|
|
assert plan.status_code == 200, plan.text
|
|
assert (plan.json()["name"], plan.json()["allowed"]) == ("plan_me", True)
|
|
|
|
boards = f"{settings.API_V1_STR}/dashboards"
|
|
board = client.post(
|
|
f"{boards}/", headers=superuser_token_headers, json={"name": "plan_board"}
|
|
).json()
|
|
try:
|
|
plan = client.post(
|
|
f"{boards}/{board['id']}/rename/plan",
|
|
headers=superuser_token_headers,
|
|
json=body,
|
|
)
|
|
assert plan.status_code == 200 and plan.json()["kind"] == "dashboard"
|
|
finally:
|
|
client.delete(f"{boards}/{board['id']}", headers=superuser_token_headers)
|
|
finally:
|
|
client.delete(f"{flows}/{flow_id}", headers=superuser_token_headers)
|