Compare commits

..
1 Commits
Author SHA1 Message Date
Spark 851a7b17d2 7.80.0 — Émission d'événements vers le journal du service d'agents (Lot 2)
FlowDeck CI / lint (push) Failing after 1m29s
FlowDeck CI / test (push) In progress
FlowDeck CI / docker (push) Blocked by required conditions
2026-10-22 20:25:36 +00:00
11 changed files with 471 additions and 1 deletions
+24
View File
@@ -1,5 +1,29 @@
# Changelog - FlowDeck
## v7.80.0 (2026-10-22) — Émission d'événements vers le journal du service d'agents (Lot 2)
- **Nouveau module `app/services/agent_evenements.py`** : Flowdeck
émet ses mutations significatives vers le journal d'activité du
service d'agents (`POST /api/v1/evenements`, contrat ACC-0.2) :
enveloppe {id, type, occurred_at, actor, domain, source_app,
object{ref, label, url}} — une référence et un libellé, jamais le
contenu — signée **HMAC-SHA256** (en-tête `X-Draco-Signature`)
avec la clé propre à Flowdeck. Envoi en fil d'arrière-plan, délai
maximal 2 s, erreurs avalées : **inactif tant que
`AGENT_EVENEMENTS_CLE` n'est pas posée** dans l'environnement, et
une panne du service ne casse ni ne ralentit jamais le métier.
- **Événements émis** : `flowdeck.page.created` /
`flowdeck.page.updated` / `flowdeck.page.deleted` et
`flowdeck.collection.created`, branchés sur les surfaces réelles
de mutation : API de session (pages du board, pages et
collections des bases), API publique v2 (acteur « user ») et
outils MCP `create_page` / `update_page` / `create_collection`
(acteur « agent:mcp », dans le périmètre du jeton). Références
`page:<id>` / `collection:<id>`, liens profonds vers la page
(`/board/pages/<id>`) ou la vue de sa collection.
- **Aucun événement meeting** : la décision A/B sur le bloc
meeting reste ouverte, le sujet est explicitement exclu.
## v7.79.0 (2026-10-22) — Panneau Spark v2 (Lot 1)
- **Panneau Spark** (`draco-panneau.js/css`, vendangés dans les 4
+1 -1
View File
@@ -1 +1 @@
7.79.0
7.80.0
+12
View File
@@ -102,6 +102,9 @@ def create_collection_v2(request: Request, authorization: str | None = Header(de
conn.commit()
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
audit_log(user, "collection.create", "collection", cid, name, request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.collection.created", f"collection:{cid}", name,
url=url_collection(cid), actor="user")
data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)}
key = (request.headers.get("Idempotency-Key") or "").strip()
if key:
@@ -349,6 +352,9 @@ def create_collection_page_v2(collection_id: int, request: Request, authorizatio
conn.commit()
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
audit_log(user, "page.create", "collection_page", pid, title, request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.created", f"page:{pid}", title,
url=url_collection(collection_id), actor="user")
try:
run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}))
except Exception:
@@ -426,6 +432,9 @@ def patch_page_v2(page_id: int, request: Request, authorization: str | None = He
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
conn.commit()
audit_log(user, "page.update", "collection_page", page_id, "", request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.updated", f"page:{page_id}", title,
url=url_collection(row["collection_id"]), actor="user")
try:
run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}))
except Exception:
@@ -444,6 +453,9 @@ def delete_page_v2(page_id: int, request: Request, authorization: str | None = H
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
conn.commit()
audit_log(user, "page.delete", "collection_page", page_id, "", request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.deleted", f"page:{page_id}", row["title"],
url=url_collection(row["collection_id"]), actor="user")
try:
run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}))
except Exception:
+3
View File
@@ -86,6 +86,9 @@ def delete_page(request: Request, page_id: int):
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
conn.commit()
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
from app.services.agent_evenements import emettre, url_page
emettre("flowdeck.page.deleted", f"page:{page_id}", row["title"] or "",
url=url_page(page_id), actor="user")
return {"status": "ok", "deleted": page_id, "title": row["title"]}
+8
View File
@@ -55,6 +55,9 @@ def create_page(request: Request, title: str = Query(default=""),
page_id = cur.lastrowid
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
"workspace": ws_key, "parent_id": parent_id}))
from app.services.agent_evenements import emettre, url_page
emettre("flowdeck.page.created", f"page:{page_id}", page_title,
url=url_page(page_id), actor="user")
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
except Exception as e:
logger.error("create_page failed: %s", e)
@@ -109,9 +112,14 @@ def update_page(request: Request, page_id: int, title: str = Query(default=""),
if content_format:
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
conn.commit()
trow = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
page_label = (trow["title"] if trow else title) or ""
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
"content_format": content_format or "markdown",
"actor_id": user.get("id")}))
from app.services.agent_evenements import emettre, url_page
emettre("flowdeck.page.updated", f"page:{page_id}", page_label,
url=url_page(page_id), actor="user")
return {"status": "ok"}
+3
View File
@@ -118,6 +118,9 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
conn.commit()
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.collection.created", f"collection:{collection_id}", name,
url=url_collection(collection_id), actor="user")
return {"id": collection_id, "name": name, "status": "created"}
+3
View File
@@ -119,4 +119,7 @@ def create_page_api(request: Request, collection_id: int, body: dict = Body(defa
"collection_id": collection_id,
"title": title,
}))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.created", f"page:{page_id}", title,
url=url_collection(collection_id), actor="user")
return {"id": page_id, "title": title, "status": "created"}
+6
View File
@@ -149,6 +149,9 @@ def update_page_api(request: Request, page_id: int, body: dict = Body(default={}
user = _current_user(request)
notify_assignment(existing["collection_id"], page_id, title,
stored, props, user.get("id"))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.updated", f"page:{page_id}", title,
url=url_collection(existing["collection_id"]), actor="user")
return {"id": page_id, "status": "updated"}
@@ -178,6 +181,9 @@ def delete_page_api(request: Request, page_id: int):
"page_id": page_id,
"collection_id": existing["collection_id"],
}))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.deleted", f"page:{page_id}", existing["title"],
url=url_collection(existing["collection_id"]), actor="user")
return {"id": page_id, "status": "deleted"}
+9
View File
@@ -237,6 +237,9 @@ def _appel_outil(nom: str, args: dict, user_id: int):
conn.commit()
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(cur.lastrowid,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.created", f"page:{page['id']}", title,
url=url_collection(collection_id), actor="agent:mcp")
return _page_resume(page)
if nom == "update_page":
try:
@@ -265,6 +268,9 @@ def _appel_outil(nom: str, args: dict, user_id: int):
conn.commit()
updated = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(page_id,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.updated", f"page:{page_id}", title,
url=url_collection(page["collection_id"]), actor="agent:mcp")
return _page_resume(updated)
if nom == "create_collection":
name = str(args.get("name") or "").strip()
@@ -287,6 +293,9 @@ def _appel_outil(nom: str, args: dict, user_id: int):
conn.commit()
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(new_id,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.collection.created", f"collection:{new_id}", name,
url=url_collection(new_id), actor="agent:mcp")
return {"id": coll["id"], "name": coll["name"],
"description": coll["description"], "icon": coll["icon"]}
return {"erreur": f"outil inconnu : {nom}"}
+120
View File
@@ -0,0 +1,120 @@
"""Émission d'événements vers le journal du service d'agents
(Fusion, Lot 2 — contrat ACC-0.2).
Flowdeck émet ses mutations significatives (pages et collections)
vers `POST /api/v1/evenements` du service d'agents, qui les expose
dans le « Journal d'aujourd'hui » de la console. L'enveloppe ne
contient qu'une référence, un libellé et un lien — jamais le
contenu de l'objet : {id, type, occurred_at, actor, domain,
source_app, object{ref, label, url}}.
Authentification : signature HMAC-SHA256 du corps brut, en-tête
`X-Draco-Signature`, avec la clé propre à Flowdeck
(`AGENT_EVENEMENTS_CLE`, jamais en dur). Sans clé, le module est
INACTIF : aucun appel, aucun bruit.
Discipline « au mieux » (même esprit que le journal du service) :
l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s,
et toute erreur est avalée avec une ligne de journal — une panne
du récepteur ne doit jamais casser ni ralentir le métier.
Aucun événement « meeting » n'est émis ici : la décision A/B de
Bruno sur le bloc meeting de Flowdeck est toujours ouverte.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import threading
import uuid
from datetime import datetime, timezone
import httpx
log = logging.getLogger("flowdeck.evenements")
SOURCE_APP = "flowdeck"
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
DELAI_S = 2.0
def _cle() -> str:
return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip()
def _url() -> str:
return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT
def actif() -> bool:
"""L'émission n'existe que si la clé de Flowdeck est configurée."""
return bool(_cle())
def url_page(page_id: int) -> str:
"""Lien profond d'une page d'espace de travail (vue HTML du board)."""
return f"/board/pages/{page_id}"
def url_collection(collection_id: int) -> str:
"""Lien profond d'une collection (sa vue tableau). Une page de
collection n'a pas d'URL autonome : elle s'ouvre dans cette vue."""
return f"/db/{collection_id}/view/table"
def construire_enveloppe(type_: str, objet_ref: str, label: str,
url: str = "/", domain: str = "pro",
actor: str | None = None) -> tuple[dict, bytes]:
"""L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est
signé puis envoyé — la signature porte sur ces octets-là)."""
env = {
"id": uuid.uuid4().hex,
"type": type_,
"occurred_at": datetime.now(timezone.utc).isoformat(),
"actor": actor or "user",
"domain": domain or "pro",
"source_app": SOURCE_APP,
"object": {"ref": objet_ref, "label": label or "", "url": url or "/"},
}
corps = json.dumps(env, ensure_ascii=False).encode("utf-8")
return env, corps
def _signer(corps: bytes) -> str:
return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest()
def _envoyer(corps: bytes) -> None:
"""Le POST signé, dans le fil d'arrière-plan. Avale tout : le
journal distant est un confort, jamais une dépendance."""
try:
with httpx.Client(timeout=DELAI_S, trust_env=False) as client:
client.post(
_url(), content=corps,
headers={"Content-Type": "application/json",
"X-Draco-Signature": _signer(corps)},
)
except Exception as exc: # panne, délai, DNS… : on log et on passe
log.warning("événement non livré au journal du service (%s)", exc)
def emettre(type_: str, objet_ref: str, label: str, url: str = "/",
domain: str = "pro", actor: str | None = None) -> bool:
"""Émet un événement en tâche de fond. Retourne True si l'envoi
a été déclenché, False si le module est inactif (pas de clé) ou
si l'enveloppe n'a pas pu être construite. Ne lève jamais."""
if not actif():
return False
try:
_env, corps = construire_enveloppe(type_, objet_ref, label, url,
domain, actor)
except Exception:
log.warning("événement %s non construit", type_, exc_info=True)
return False
threading.Thread(target=_envoyer, args=(corps,), daemon=True,
name="flowdeck-evenements").start()
return True
+282
View File
@@ -0,0 +1,282 @@
"""Émission d'événements vers le journal du service d'agents
(Fusion, Lot 2 — contrat ACC-0.2).
Un faux récepteur local capture les POST signés HMAC et les vérifie
comme le fait le service (journal.verifier_signature) ; on prouve
aussi l'inactivité sans clé et la non-régression métier quand le
récepteur est en panne. Surfaces couvertes : API de session (board
et collections), API publique v2 et MCP (acteur « agent:mcp »).
"""
import hashlib
import hmac
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from app.db import get_conn
CLE = "cle-flowdeck-de-test"
class _Recepteur(BaseHTTPRequestHandler):
recus = []
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
corps = self.rfile.read(n)
type(self).recus.append(
{"headers": {k.lower(): v for k, v in self.headers.items()},
"corps": corps})
self.send_response(201)
self.end_headers()
def log_message(self, *a):
pass
def _demarrer(monkeypatch):
_Recepteur.recus = []
serveur = HTTPServer(("127.0.0.1", 0), _Recepteur)
port = serveur.server_address[1]
threading.Thread(target=serveur.serve_forever, daemon=True).start()
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
f"http://127.0.0.1:{port}/api/v1/evenements")
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
return serveur
def _attendre(nombre, delai=6.0):
"""Attend `nombre` événements ; vérifie la signature HMAC de
chacun (comme le service) et retourne la liste des enveloppes."""
fin = time.time() + delai
while time.time() < fin and len(_Recepteur.recus) < nombre:
time.sleep(0.05)
assert len(_Recepteur.recus) >= nombre, \
f"{len(_Recepteur.recus)} événement(s) reçu(s), {nombre} attendus"
envs = []
for recu in _Recepteur.recus:
sig = recu["headers"].get("x-draco-signature", "")
attendu = hmac.new(CLE.encode(), recu["corps"],
hashlib.sha256).hexdigest()
assert hmac.compare_digest(sig.lower(), attendu), \
"signature HMAC invalide"
env = json.loads(recu["corps"].decode("utf-8"))
assert env["source_app"] == "flowdeck"
assert env["id"] and env["occurred_at"]
envs.append(env)
return envs
def _par_type(envs, type_):
return [e for e in envs if e["type"] == type_]
def test_enveloppe_et_signature():
from app.services import agent_evenements
env, corps = agent_evenements.construire_enveloppe(
"flowdeck.page.created", "page:42", "Page de test",
url="/board/pages/42", actor="user")
assert env["source_app"] == "flowdeck"
assert env["domain"] == "pro"
assert env["actor"] == "user"
assert env["object"] == {"ref": "page:42", "label": "Page de test",
"url": "/board/pages/42"}
assert json.loads(corps.decode("utf-8")) == env
sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
assert len(sig) == 64
def test_cycle_de_vie_api_session(client, monkeypatch):
serveur = _demarrer(monkeypatch)
try:
# Page d'espace de travail (board) : création, màj, suppression.
r = client.post("/board/api/pages", params={"title": "Page journal"})
assert r.status_code == 200, r.text
page_id = r.json()["id"]
r = client.put(f"/board/api/pages/{page_id}",
params={"title": "Page journal (éditée)"})
assert r.status_code == 200, r.text
r = client.delete(f"/board/api/pages/{page_id}")
assert r.status_code == 200, r.text
# Collection puis sa page : création, màj, suppression.
r = client.post("/db/api", json={"name": "Collection journal"})
assert r.status_code == 200, r.text
cid = r.json()["id"]
r = client.post(f"/db/{cid}/pages/api",
json={"title": "Ligne journal"})
assert r.status_code == 200, r.text
ligne_id = r.json()["id"]
r = client.put(f"/db/pages/{ligne_id}/api",
json={"title": "Ligne journal (éditée)"})
assert r.status_code == 200, r.text
r = client.delete(f"/db/pages/{ligne_id}/api")
assert r.status_code == 200, r.text
envs = _attendre(7)
crees = _par_type(envs, "flowdeck.page.created")
assert len(crees) == 2
urls = {e["object"]["url"] for e in crees}
assert f"/board/pages/{page_id}" in urls
assert f"/db/{cid}/view/table" in urls
board = next(e for e in crees
if e["object"]["url"] == f"/board/pages/{page_id}")
assert board["object"]["ref"] == f"page:{page_id}"
assert board["object"]["label"] == "Page journal"
assert board["actor"] == "user"
maj = _par_type(envs, "flowdeck.page.updated")
assert len(maj) == 2
labels = {e["object"]["label"] for e in maj}
assert labels == {"Page journal (éditée)", "Ligne journal (éditée)"}
suppr = _par_type(envs, "flowdeck.page.deleted")
assert len(suppr) == 2
refs = {e["object"]["ref"] for e in suppr}
assert refs == {f"page:{page_id}", f"page:{ligne_id}"}
coll = _par_type(envs, "flowdeck.collection.created")
assert len(coll) == 1
assert coll[0]["object"] == {
"ref": f"collection:{cid}", "label": "Collection journal",
"url": f"/db/{cid}/view/table"}
assert coll[0]["actor"] == "user"
finally:
serveur.shutdown()
def test_api_publique_v2(client, monkeypatch):
serveur = _demarrer(monkeypatch)
try:
r = client.post("/auth/register", json={
"email": "[email protected]", "password": "secret123",
"name": "journal"})
assert r.status_code == 200, r.text
tok = client.post("/api/v1/token").json()["token"]
h1 = {"Authorization": f"Bearer {tok}"}
r = client.post("/api/v2/tokens",
json={"name": "ci", "scopes": "read,write"},
headers=h1)
assert r.status_code == 200, r.text
h = {"Authorization": f"Bearer {r.json()['token']}"}
r = client.post("/api/v2/collections",
json={"name": "Collection v2"}, headers=h)
assert r.status_code == 201, r.text
cid = r.json()["id"]
r = client.post(f"/api/v2/collections/{cid}/pages",
json={"title": "Page v2"}, headers=h)
assert r.status_code == 201, r.text
pid = r.json()["id"]
r = client.patch(f"/api/v2/pages/{pid}",
json={"title": "Page v2 (éditée)"}, headers=h)
assert r.status_code == 200, r.text
r = client.delete(f"/api/v2/pages/{pid}", headers=h)
assert r.status_code == 200, r.text
envs = _attendre(4)
types = [e["type"] for e in envs]
assert types.count("flowdeck.collection.created") == 1
assert types.count("flowdeck.page.created") == 1
assert types.count("flowdeck.page.updated") == 1
assert types.count("flowdeck.page.deleted") == 1
assert all(e["actor"] == "user" for e in envs)
coll = _par_type(envs, "flowdeck.collection.created")[0]
assert coll["object"]["ref"] == f"collection:{cid}"
page = _par_type(envs, "flowdeck.page.created")[0]
assert page["object"]["ref"] == f"page:{pid}"
assert page["object"]["url"] == f"/db/{cid}/view/table"
finally:
serveur.shutdown()
@pytest.fixture()
def jeton_mcp(client):
brut = "fd_test_token_journal"
with get_conn() as conn:
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) "
"VALUES (1, 'service-agents', ?, ?, 0)",
(hashlib.sha256(brut.encode()).hexdigest(), brut[:12]))
conn.commit()
return brut
def _appel_mcp(client, jeton, rid, nom, args=None):
r = client.post(
"/mcp",
json={"jsonrpc": "2.0", "id": rid, "method": "tools/call",
"params": {"name": nom, "arguments": args or {}}},
headers={"Authorization": f"Bearer {jeton}"})
assert r.status_code == 200, r.text
return json.loads(r.json()["result"]["content"][0]["text"])
def test_mcp_acteur_agent(client, monkeypatch, jeton_mcp):
serveur = _demarrer(monkeypatch)
try:
coll = _appel_mcp(client, jeton_mcp, 1, "create_collection",
{"name": "Collection MCP"})
cid = coll["id"]
page = _appel_mcp(client, jeton_mcp, 2, "create_page",
{"collection_id": cid, "title": "Page MCP"})
pid = page["id"]
_appel_mcp(client, jeton_mcp, 3, "update_page",
{"id": pid, "title": "Page MCP (éditée)"})
envs = _attendre(3)
assert all(e["actor"] == "agent:mcp" for e in envs)
coll_env = _par_type(envs, "flowdeck.collection.created")
assert len(coll_env) == 1
assert coll_env[0]["object"] == {
"ref": f"collection:{cid}", "label": "Collection MCP",
"url": f"/db/{cid}/view/table"}
creee = _par_type(envs, "flowdeck.page.created")
assert len(creee) == 1
assert creee[0]["object"]["ref"] == f"page:{pid}"
assert creee[0]["object"]["label"] == "Page MCP"
maj = _par_type(envs, "flowdeck.page.updated")
assert len(maj) == 1
assert maj[0]["object"]["label"] == "Page MCP (éditée)"
finally:
serveur.shutdown()
def test_inactif_sans_cle(client, monkeypatch):
serveur = _demarrer(monkeypatch)
monkeypatch.delenv("AGENT_EVENEMENTS_CLE")
try:
from app.services import agent_evenements
assert agent_evenements.actif() is False
assert agent_evenements.emettre(
"flowdeck.page.created", "page:1", "x") is False
r = client.post("/board/api/pages", params={"title": "Sans journal"})
assert r.status_code == 200, r.text
r = client.post("/db/api", json={"name": "Collection sans journal"})
assert r.status_code == 200, r.text
time.sleep(0.6)
assert _Recepteur.recus == []
finally:
serveur.shutdown()
def test_recepteur_en_panne_ne_casse_rien(client, monkeypatch):
# Port fermé : connexion refusée dans le fil d'arrière-plan.
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
"http://127.0.0.1:59998/api/v1/evenements")
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
r = client.post("/board/api/pages", params={"title": "Malgré la panne"})
assert r.status_code == 200, r.text
page_id = r.json()["id"]
r = client.get(f"/board/api/pages/{page_id}")
assert r.status_code == 200, r.text
assert r.json()["title"] == "Malgré la panne"
r = client.post("/db/api", json={"name": "Collection malgré la panne"})
assert r.status_code == 200, r.text