feat: Fusion bloc 1 — barre d'apps commune (ACC-1.1, #17) et références croisées external_ref (ACC-1.3, #18)
This commit is contained in:
@@ -1,5 +1,29 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.72.0 (2026-10-20) — Fusion bloc 1 : barre d'apps commune + références croisées
|
||||
|
||||
### Added
|
||||
|
||||
- **Barre d'apps commune dracodev** (Fusion ACC-1.1, issue #17) : snippet
|
||||
figé vendangé depuis `dracodev-commons` v0.1.0
|
||||
(`app/templates/_draco_bar.html`, `static/css/draco-barre.css`,
|
||||
`static/js/vendor/draco-barre.js`) — navigation vers ObsiGate,
|
||||
Flowdeck, TikTak et Kanbante, Flowdeck marquée active, domaine
|
||||
mémorisé ; le rail pilote le hamburger natif et les alertes ouvrent
|
||||
la cloche native de la page. Masquée en mode `embed`.
|
||||
- **Références croisées `external_ref`** (Fusion ACC-1.3, issue #18) :
|
||||
migration 50 (`external_refs`), service `app/services/external_refs.py`
|
||||
(parseur des URL profondes du contrat figé) et endpoints
|
||||
`GET/POST /api/pages/{id}/refs`, `GET/POST /api/collections/{id}/refs`,
|
||||
`DELETE /api/refs/{id}` (permissions page/collection respectées, 422
|
||||
explicite sinon). Section « Liens » dans l'éditeur de page
|
||||
(`static/js/draco-refs.js`) : ajout par collage d'URL, pastille de
|
||||
couleur de l'app cible, retrait en un clic.
|
||||
- **Route profonde `/collections/{id}`** (contrat ACC-0.1) : redirection
|
||||
303 vers la vue native de la base `/db/{id}` (404 si inconnue). La
|
||||
route `/pages/{id}` existait déjà.
|
||||
|
||||
|
||||
## v7.71.5 (2026-10-10) — Galerie système : +94 templates (135 au total)
|
||||
|
||||
### Added
|
||||
|
||||
+3
-1
@@ -24,6 +24,7 @@ from app.routers import (
|
||||
collections,
|
||||
dashboard,
|
||||
export,
|
||||
external_refs,
|
||||
library,
|
||||
my_tasks,
|
||||
notes,
|
||||
@@ -191,7 +192,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.71.5",
|
||||
version="7.72.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -228,6 +229,7 @@ app.include_router(projects.backups_router)
|
||||
app.include_router(api.router)
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(external_refs.router) # Fusion ACC-1.3 — références croisées
|
||||
app.include_router(my_tasks.router)
|
||||
app.include_router(workspace.router)
|
||||
# Partage public :-exempt de la dépendance d'authentification du router.
|
||||
|
||||
@@ -2025,3 +2025,37 @@ def _migration_template_runs(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_runs_tpl "
|
||||
"ON template_runs(template_id, created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(50, "fusion: références croisées `external_refs` (ACC-1.3)")
|
||||
def _migration_external_refs(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 50 — table des références croisées entre objets.
|
||||
|
||||
Modèle figé par dracodev-commons (contrats/external_ref.schema.json,
|
||||
v0.1.0) : la référence vit chez le propriétaire de l'objet source
|
||||
(page ou projet/collection Flowdeck) ; une seule source de vérité
|
||||
par objet, des liens — jamais des copies du contenu cible.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS external_refs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
owner_type TEXT NOT NULL,
|
||||
owner_id INTEGER NOT NULL,
|
||||
target_app TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
target_url TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
domain TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'ok',
|
||||
created_by TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_external_refs_owner "
|
||||
"ON external_refs(owner_type, owner_id)"
|
||||
)
|
||||
|
||||
@@ -162,7 +162,10 @@ def view_page(request: Request, page_id: int):
|
||||
from app.routers.dashboard import _nav_breadcrumb
|
||||
with get_conn() as conn:
|
||||
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
|
||||
# Fusion ACC-1.3 — références croisées de la page (section « Liens »).
|
||||
from app.services import external_refs as _refs_svc
|
||||
page_refs = _refs_svc.list_refs("flowdeck.page", page_id)
|
||||
ctx = {**sidebar, "page": page, "page_refs": page_refs, "page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Endpoints des références croisées — Fusion ACC-1.3 (Flowdeck).
|
||||
|
||||
Création par collage d'une URL profonde d'une autre application
|
||||
(ObsiGate, TikTak, Kanbante, Flowdeck) — zéro couplage serveur :
|
||||
l'URL est reconnue par les formes figées du contrat
|
||||
``dracodev-commons/contrats/liens-profonds.md`` (voir
|
||||
``app.services.external_refs``). La route ``GET /collections/{id}``
|
||||
honore la forme canonique du même contrat en redirigeant vers la vue
|
||||
native de la base (``/db/{id}``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import external_refs as refs_svc
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
logger = logging.getLogger("flowdeck.external_refs")
|
||||
|
||||
router = APIRouter(tags=["ExternalRefs"])
|
||||
|
||||
_FORMES_ACCEPTEES = (
|
||||
"URL non reconnue — formes acceptées : TikTak ?task= ou ?list=, "
|
||||
"ObsiGate #file=, Flowdeck /pages/ ou /collections/, "
|
||||
"issue Gitea (carte Kanbante)"
|
||||
)
|
||||
|
||||
|
||||
def _user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Session requise")
|
||||
return user
|
||||
|
||||
|
||||
def _check(owner_type: str, owner_id: int, user: dict, edit: bool) -> None:
|
||||
pm = PermissionManager(user["id"])
|
||||
if owner_type == "flowdeck.page":
|
||||
ok = pm.can_edit_page(owner_id) if edit else pm.can_view_page(owner_id)
|
||||
elif owner_type == "flowdeck.project":
|
||||
ok = pm.can_edit_collection(owner_id) if edit else pm.can_view_collection(owner_id)
|
||||
else:
|
||||
raise HTTPException(400, "Type de propriétaire non géré ici")
|
||||
if not ok:
|
||||
raise HTTPException(403, "Accès refusé")
|
||||
|
||||
|
||||
def _list(owner_type: str, owner_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_check(owner_type, owner_id, user, edit=False)
|
||||
return {"refs": refs_svc.list_refs(owner_type, owner_id)}
|
||||
|
||||
|
||||
def _add(owner_type: str, owner_id: int, request: Request, payload: dict):
|
||||
user = _user(request)
|
||||
_check(owner_type, owner_id, user, edit=True)
|
||||
parsed = refs_svc.parse_deep_link(payload.get("url") or "")
|
||||
if parsed is None:
|
||||
raise HTTPException(422, _FORMES_ACCEPTEES)
|
||||
target_app, target_type, target_id, default_label = parsed
|
||||
label = (payload.get("label") or "").strip() or default_label
|
||||
ref = refs_svc.add_ref(
|
||||
user["id"], owner_type, owner_id, target_app, target_type, target_id,
|
||||
(payload.get("url") or "").strip(), label,
|
||||
)
|
||||
return JSONResponse(ref, status_code=201)
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/refs")
|
||||
def list_page_refs(page_id: int, request: Request):
|
||||
return _list("flowdeck.page", page_id, request)
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/refs")
|
||||
def add_page_ref(page_id: int, request: Request, payload: dict = Body(...)):
|
||||
return _add("flowdeck.page", page_id, request, payload)
|
||||
|
||||
|
||||
@router.get("/api/collections/{collection_id}/refs")
|
||||
def list_collection_refs(collection_id: int, request: Request):
|
||||
return _list("flowdeck.project", collection_id, request)
|
||||
|
||||
|
||||
@router.post("/api/collections/{collection_id}/refs")
|
||||
def add_collection_ref(collection_id: int, request: Request, payload: dict = Body(...)):
|
||||
return _add("flowdeck.project", collection_id, request, payload)
|
||||
|
||||
|
||||
@router.delete("/api/refs/{ref_id}")
|
||||
def delete_ref(ref_id: int, request: Request):
|
||||
user = _user(request)
|
||||
ref = refs_svc.get_ref(ref_id)
|
||||
if ref is None:
|
||||
raise HTTPException(404, "Lien introuvable")
|
||||
_check(ref["owner_type"], int(ref["owner_id"]), user, edit=True)
|
||||
refs_svc.delete_ref(ref_id)
|
||||
return {"deleted": ref_id}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}")
|
||||
def view_collection_deep_link(collection_id: int, request: Request):
|
||||
"""Forme canonique Fusion (ACC-0.1) : /collections/<id> → /db/<id>."""
|
||||
_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
# Réponse directe, comme view_page : le gestionnaire global de
|
||||
# l'app convertirait une HTTPException 404 en redirection 302.
|
||||
return HTMLResponse("<h2>Collection introuvable</h2>", status_code=404)
|
||||
return RedirectResponse(f"/db/{collection_id}", status_code=303)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Références croisées ``external_ref`` — Fusion ACC-1.3 (Flowdeck).
|
||||
|
||||
Modèle figé par le programme Fusion dans ``dracodev-commons``
|
||||
(``contrats/external_ref.schema.json``, v0.1.0) : la référence vit chez
|
||||
le propriétaire de l'objet source — ici une page ou un projet
|
||||
(collection) Flowdeck — et pointe vers un objet d'une autre application
|
||||
par son URL profonde (``contrats/liens-profonds.md``). Une seule source
|
||||
de vérité par objet : des références, jamais des copies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
APP_COLORS = {
|
||||
"obsigate": "#7a5af5",
|
||||
"flowdeck": "#2f6fed",
|
||||
"tiktak": "#e8762c",
|
||||
"kanbante": "#0d9488",
|
||||
}
|
||||
|
||||
|
||||
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||
"""Reconnaît une URL profonde des applications dracodev.
|
||||
|
||||
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
|
||||
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
|
||||
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
|
||||
"""
|
||||
try:
|
||||
u = urlparse((url or "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
host = (u.hostname or "").lower()
|
||||
if host == "tiktak.dracodev.net":
|
||||
q = parse_qs(u.query)
|
||||
if q.get("task"):
|
||||
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
|
||||
if q.get("list"):
|
||||
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
|
||||
return None
|
||||
if host == "og.dracodev.net":
|
||||
frag = u.fragment or ""
|
||||
if frag.startswith("file="):
|
||||
cible = unquote(frag[5:])
|
||||
if ":" in cible:
|
||||
nom = cible.rsplit("/", 1)[-1]
|
||||
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
|
||||
return None
|
||||
if host == "flowdeck.dracodev.net":
|
||||
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
|
||||
if m:
|
||||
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
|
||||
m = re.fullmatch(r"/collections/(\d+)", u.path or "")
|
||||
if m:
|
||||
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
|
||||
return None
|
||||
if host == "git.dracodev.net":
|
||||
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
|
||||
if m:
|
||||
owner, repo, num = m.groups()
|
||||
return ("kanbante", "card", f"{owner}/{repo}#{num}", f"Kanbante · {repo} #{num}")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _row_to_ref(row) -> dict:
|
||||
d = dict(row)
|
||||
d["app_color"] = APP_COLORS.get(d["target_app"], "#888")
|
||||
return d
|
||||
|
||||
|
||||
def list_refs(owner_type: str, owner_id: int) -> list[dict]:
|
||||
"""Références posées sur un objet Flowdeck, dans l'ordre de création."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM external_refs WHERE owner_type = ? AND owner_id = ? "
|
||||
"ORDER BY created_at, id",
|
||||
(owner_type, owner_id),
|
||||
).fetchall()
|
||||
return [_row_to_ref(r) for r in rows]
|
||||
|
||||
|
||||
def add_ref(
|
||||
user_id: int,
|
||||
owner_type: str,
|
||||
owner_id: int,
|
||||
target_app: str,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
target_url: str,
|
||||
label: str,
|
||||
) -> dict:
|
||||
"""Ajoute une référence à un objet et la retourne (avec son ``id``)."""
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO external_refs (user_id, owner_type, owner_id, target_app, "
|
||||
"target_type, target_id, target_url, label) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(user_id, owner_type, owner_id, target_app, target_type, target_id,
|
||||
target_url, label),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM external_refs WHERE id = ?", (cur.lastrowid,)
|
||||
).fetchone()
|
||||
return _row_to_ref(row)
|
||||
|
||||
|
||||
def get_ref(ref_id: int) -> dict | None:
|
||||
"""Une référence par son identifiant, ou ``None``."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM external_refs WHERE id = ?", (ref_id,)
|
||||
).fetchone()
|
||||
return _row_to_ref(row) if row else None
|
||||
|
||||
|
||||
def delete_ref(ref_id: int) -> bool:
|
||||
"""Retire une référence ; ``True`` si elle existait."""
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM external_refs WHERE id = ?", (ref_id,))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
@@ -0,0 +1,96 @@
|
||||
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
|
||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.0
|
||||
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
|
||||
passe par une nouvelle version de commons.
|
||||
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js. #}
|
||||
<header class="draco-bar" data-current="flowdeck">
|
||||
<div class="draco-bar-zone draco-bar-gauche">
|
||||
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||
<a class="draco-brand" href="/" aria-label="dracodev">
|
||||
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||
</a>
|
||||
<nav class="draco-apptabs" aria-label="Applications">
|
||||
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
|
||||
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
|
||||
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
|
||||
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
|
||||
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
|
||||
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-centre">
|
||||
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
|
||||
<span class="draco-search-ico">⌕</span>
|
||||
<span class="draco-search-txt">Rechercher partout…</span>
|
||||
<kbd>⌘K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-droite">
|
||||
<div class="draco-domaines" role="group" aria-label="Domaine">
|
||||
<span class="draco-domaines-lbl">Domaine</span>
|
||||
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
|
||||
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
|
||||
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
|
||||
</div>
|
||||
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
|
||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
<span class="draco-alertes-txt">Alertes</span>
|
||||
<span class="draco-badge" data-badge="alertes" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
|
||||
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
||||
<span class="draco-avatar" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
/* Configuration Flowdeck de la barre commune (Fusion ACC-1.1).
|
||||
Phase 1 : navigation entre apps et domaine mémorisé localement ;
|
||||
le rail pilote le hamburger natif, les alertes ouvrent la cloche
|
||||
native de la page ; la recherche fédérée et Spark arrivent avec
|
||||
les phases suivantes du programme Fusion. */
|
||||
window.DRACO_BAR_CONFIG = {
|
||||
current: "flowdeck",
|
||||
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||
counts: {},
|
||||
onAction: function (act, el) {
|
||||
var dire = function (m) {
|
||||
var d = document.createElement("div");
|
||||
d.textContent = m;
|
||||
d.style.cssText = "position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:3000;background:#1f1f1f;color:#eee;padding:10px 16px;border-radius:10px;font:500 13.5px system-ui;box-shadow:0 6px 24px rgb(0 0 0/.35)";
|
||||
document.body.appendChild(d);
|
||||
setTimeout(function () { d.remove(); }, 3400);
|
||||
};
|
||||
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
||||
if (act === "rail") { var h = document.querySelector(".hamburger-btn"); if (h) h.click(); return; }
|
||||
if (act === "alertes") {
|
||||
var b = document.querySelector(".fd-notif-bell button");
|
||||
if (b) { b.click(); } else { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); }
|
||||
return;
|
||||
}
|
||||
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
|
||||
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
|
||||
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
|
||||
if (act === "rappel-entete") {
|
||||
var bar = document.querySelector(".draco-bar");
|
||||
if (bar) bar.style.display = "";
|
||||
el.hidden = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -755,6 +755,32 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Liens — références croisées (Fusion ACC-1.3) ═══
|
||||
Liste rendue côté serveur ; ajout/retrait par
|
||||
static/js/draco-refs.js (API /api/pages/<id>/refs). -->
|
||||
<section class="draco-liens" data-refs data-owner-type="flowdeck.page" data-owner-id="{{ page.id }}">
|
||||
<h3 class="draco-liens-title">Liens</h3>
|
||||
<ul class="draco-liens-list">
|
||||
{% for r in page_refs %}
|
||||
<li class="draco-liens-item">
|
||||
<span class="draco-ref-dot" style="background:{{ r.app_color }}"></span>
|
||||
<a href="{{ r.target_url }}" target="_blank" rel="noopener">{{ r.label }}</a>
|
||||
{% if page_data.can_edit %}
|
||||
<button type="button" class="draco-liens-del" data-ref-del="{{ r.id }}" title="Retirer le lien">✕</button>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if page_data.can_edit %}
|
||||
<form class="draco-liens-form" data-refs-form>
|
||||
<input type="url" required placeholder="Coller une URL profonde (TikTak, ObsiGate, Gitea, Flowdeck)…">
|
||||
<input type="text" placeholder="Libellé (optionnel)">
|
||||
<button type="submit">Lier</button>
|
||||
</form>
|
||||
<p class="draco-liens-erreur" data-refs-erreur hidden></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="editor-statusbar">
|
||||
<span x-show="saving">Saving...</span>
|
||||
<span x-show="!saving&&dirty">Unsaved</span>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
htmx lors d'une navigation partielle : la page revenait alors sans
|
||||
aucune feuille de style (composants blancs en thème sombre). -->
|
||||
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||
<style>
|
||||
/* ── Mobile responsive (v4.0.2) ── */
|
||||
@@ -146,7 +147,8 @@
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% else %} class="has-draco-bar"{% endif %}>
|
||||
{% if not embed_mode %}{% include "_draco_bar.html" %}{% endif %}
|
||||
<div class="app-layout" x-data="appState()" x-cloak>
|
||||
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
@@ -2867,6 +2869,7 @@
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/vendor/draco-barre.js?v={{ asset_version }}" defer data-cfasync="false"></script>
|
||||
|
||||
<style>
|
||||
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
|
||||
|
||||
@@ -9,6 +9,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% include "_page_editor_content.html" %}
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/js/katex.min.js?v=0.16.11" defer></script>
|
||||
<script src="/static/js/draco-refs.js?v={{ asset_version }}" defer></script>
|
||||
{% include "_page_editor_scripts.html" %}
|
||||
{% include "_page_editor_realtime.html" %}
|
||||
{% include "_database_table_scripts.html" %}
|
||||
|
||||
@@ -5309,3 +5309,36 @@ body.page-locked .get-started-toolbar { display: none !important; }
|
||||
.fd-gtpl-meta { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
|
||||
.fd-gtpl-empty { color: var(--text-dim); font-size: 13px; padding: 20px 0; text-align: center; }
|
||||
.fd-gtpl-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ── Barre d'apps commune dracodev + section « Liens » des pages
|
||||
(Fusion ACC-1.1/1.3, snippet figé dracodev-commons v0.1.0) ── */
|
||||
body.has-draco-bar .app-layout { height: calc(100vh - 60px); }
|
||||
@media (max-width: 700px) {
|
||||
body.has-draco-bar .app-layout { height: calc(100vh - 52px); }
|
||||
}
|
||||
.draco-liens {
|
||||
margin: 28px 0 8px; padding: 16px 18px;
|
||||
border: 1px solid var(--border, #e3e3e0); border-radius: 12px;
|
||||
background: var(--bg-secondary, #f7f7f5);
|
||||
}
|
||||
.draco-liens-title { margin: 0 0 10px; font-size: 15px; }
|
||||
.draco-liens-list { list-style: none; margin: 0 0 12px; padding: 0; }
|
||||
.draco-liens-item { display: flex; align-items: center; gap: 4px; padding: 4px 0; }
|
||||
.draco-ref-dot {
|
||||
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
|
||||
margin-right: 7px; flex: none;
|
||||
}
|
||||
.draco-liens-del {
|
||||
margin-left: 8px; border: 0; background: transparent; cursor: pointer;
|
||||
color: var(--text-secondary, #888); font-size: 12px;
|
||||
}
|
||||
.draco-liens-form { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.draco-liens-form input {
|
||||
flex: 1 1 200px; min-width: 0; padding: 7px 10px; border-radius: 8px;
|
||||
border: 1px solid var(--border, #e3e3e0); font: inherit; font-size: 13.5px;
|
||||
}
|
||||
.draco-liens-form button {
|
||||
padding: 7px 14px; border-radius: 8px; border: 1px solid var(--border, #e3e3e0);
|
||||
background: transparent; cursor: pointer; font: inherit; font-size: 13.5px;
|
||||
}
|
||||
.draco-liens-erreur { color: #c0392b; font-size: 13px; margin: 8px 0 0; }
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2)
|
||||
Jetons repris du prototype validé Console v7.2.
|
||||
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
||||
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
||||
|
||||
.draco-bar {
|
||||
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||
--agent: #c2437e; --danger: #cf4444;
|
||||
height: 60px; box-sizing: border-box;
|
||||
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
|
||||
align-items: center; gap: 16px; padding: 0 12px;
|
||||
background: var(--surface); border-bottom: 1px solid var(--line);
|
||||
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
|
||||
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
|
||||
}
|
||||
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
|
||||
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
|
||||
|
||||
.draco-bar-zone { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.draco-bar-centre { justify-content: center; }
|
||||
.draco-bar-droite { justify-content: flex-end; }
|
||||
|
||||
/* Marque */
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; }
|
||||
.draco-brand-mark { color: var(--agent); }
|
||||
|
||||
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
|
||||
.draco-apptabs { display: flex; gap: 6px; }
|
||||
.draco-apptab {
|
||||
position: relative; display: flex; align-items: center; gap: 8px;
|
||||
height: 44px; padding: 0 9px; border-radius: 12px;
|
||||
color: var(--ink); text-decoration: none; background: transparent;
|
||||
transition: background .18s ease;
|
||||
}
|
||||
.draco-apptab:hover { background: var(--surface-2); }
|
||||
.draco-apptab-ico {
|
||||
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
|
||||
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
|
||||
}
|
||||
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
|
||||
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
|
||||
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
|
||||
.draco-apptab[data-active="true"] { background: var(--surface-3); box-shadow: inset 0 -2px 0 var(--app, var(--agent)); }
|
||||
.draco-apptab[data-active="true"] .draco-apptab-txt { display: flex; }
|
||||
|
||||
/* Pastilles compteurs — petites, rouges, coin bas-droit (standard v7.1) */
|
||||
.draco-badge {
|
||||
position: absolute; right: -4px; bottom: -4px; min-width: 17px; height: 17px;
|
||||
padding: 0 4px; border-radius: 9px; box-sizing: border-box;
|
||||
background: #e5484d; color: #fff; font-size: 10.5px; font-weight: 700;
|
||||
display: grid; place-items: center; border: 2px solid var(--surface);
|
||||
}
|
||||
.draco-badge[hidden] { display: none; }
|
||||
|
||||
/* Recherche */
|
||||
.draco-search {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%; height: 38px;
|
||||
padding: 0 12px; border-radius: 10px; border: 1px solid var(--line);
|
||||
background: var(--surface-3); color: var(--ink-3); cursor: pointer; font: inherit;
|
||||
}
|
||||
.draco-search kbd {
|
||||
margin-left: auto; font: 600 11px system-ui; color: var(--ink-2);
|
||||
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
|
||||
}
|
||||
|
||||
/* Domaines — contrôle segmenté */
|
||||
.draco-domaines { display: flex; align-items: center; gap: 2px; background: var(--surface-3); border-radius: 10px; padding: 3px; }
|
||||
.draco-domaines-lbl { font-size: 10.5px; letter-spacing: .06em; color: var(--ink-3); padding: 0 6px; text-transform: uppercase; }
|
||||
.draco-domaines button {
|
||||
border: 0; background: transparent; color: var(--ink-2); font: inherit; font-size: 13px;
|
||||
height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer;
|
||||
}
|
||||
.draco-domaines button[data-on="true"] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / .18); }
|
||||
|
||||
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
|
||||
.draco-alertes {
|
||||
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
|
||||
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
|
||||
}
|
||||
|
||||
/* Spark */
|
||||
.draco-spark {
|
||||
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
|
||||
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
|
||||
}
|
||||
.draco-spark .draco-badge { border-color: var(--agent); }
|
||||
|
||||
.draco-icon-btn {
|
||||
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
|
||||
background: transparent; color: var(--ink-2); font: inherit; cursor: pointer;
|
||||
}
|
||||
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||
.draco-avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
|
||||
background: #2f6fed; color: #fff; font-weight: 700;
|
||||
}
|
||||
|
||||
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
|
||||
.draco-bar-rappel {
|
||||
position: fixed; top: 0; left: 16px; z-index: 60; cursor: pointer;
|
||||
background: var(--surface, #1a1c1a); border: 1px solid var(--line, #2e302c); border-top: 0;
|
||||
border-radius: 0 0 10px 10px; padding: 5px 12px; font: 600 12px system-ui; color: var(--ink-2, #a6aba2);
|
||||
}
|
||||
|
||||
/* Paliers — les zones latérales cèdent, la barre ne casse jamais */
|
||||
@media (max-width: 1440px) {
|
||||
.draco-brand-name, .draco-domaines-lbl { display: none; }
|
||||
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
|
||||
.draco-search { width: 38px; padding: 0; justify-content: center; }
|
||||
.draco-bar { grid-template-columns: auto auto 1fr; }
|
||||
.draco-bar-droite { justify-content: flex-end; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
/* Mobile : la barre du haut se condense ; la navigation d'apps passe
|
||||
en barre basse propre à chaque app (patron du prototype v5+). */
|
||||
.draco-apptabs, .draco-domaines-lbl { display: none; }
|
||||
.draco-bar { height: 52px; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Références croisées des pages — Fusion ACC-1.3 (Flowdeck).
|
||||
* Câble la section « Liens » rendue côté serveur dans l'éditeur de page :
|
||||
* ajout par collage d'URL profonde (POST /api/pages/<id>/refs) et retrait
|
||||
* (DELETE /api/refs/<id>), jeton CSRF lu dans le cookie comme le reste de
|
||||
* l'app. Après chaque mutation, la page est rechargée — l'état affiché
|
||||
* reste celui du serveur, seule source de vérité.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function csrf() {
|
||||
var m = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
|
||||
function wire(section) {
|
||||
var ownerType = section.getAttribute("data-owner-type");
|
||||
var ownerId = section.getAttribute("data-owner-id");
|
||||
var base = ownerType === "flowdeck.project"
|
||||
? "/api/collections/" + ownerId + "/refs"
|
||||
: "/api/pages/" + ownerId + "/refs";
|
||||
var erreur = section.querySelector("[data-refs-erreur]");
|
||||
|
||||
function showError(msg) {
|
||||
if (erreur) {
|
||||
erreur.textContent = msg;
|
||||
erreur.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
section.querySelectorAll("[data-ref-del]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
fetch("/api/refs/" + btn.getAttribute("data-ref-del"), {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRF-Token": csrf() }
|
||||
}).then(function (r) {
|
||||
if (r.ok) { window.location.reload(); }
|
||||
else { r.json().then(function (d) { showError(d.detail || "HTTP " + r.status); }); }
|
||||
}).catch(function () { showError("Réseau indisponible"); });
|
||||
});
|
||||
});
|
||||
|
||||
var form = section.querySelector("[data-refs-form]");
|
||||
if (form) {
|
||||
form.addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
var inputs = form.querySelectorAll("input");
|
||||
fetch(base, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf() },
|
||||
body: JSON.stringify({ url: inputs[0].value.trim(), label: inputs[1].value.trim() || null })
|
||||
}).then(function (r) {
|
||||
if (r.ok) { window.location.reload(); }
|
||||
else { r.json().then(function (d) { showError(d.detail || "HTTP " + r.status); }); }
|
||||
}).catch(function () { showError("Réseau indisponible"); });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelectorAll("[data-refs]").forEach(wire);
|
||||
});
|
||||
})();
|
||||
Vendored
+56
@@ -0,0 +1,56 @@
|
||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2)
|
||||
*
|
||||
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
||||
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||
* proscrits dans tout le programme).
|
||||
*
|
||||
* Configuration attendue avant le chargement :
|
||||
* window.DRACO_BAR_CONFIG = {
|
||||
* current: "tiktak", // app courante
|
||||
* domain: "pro", // domaine actif
|
||||
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
||||
* onAction: function (act, el) { … } // l'app relaie les actions
|
||||
* } // qu'elle ne connaît pas
|
||||
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
|
||||
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
var cfg = window.DRACO_BAR_CONFIG || {};
|
||||
|
||||
function setCounts(counts) {
|
||||
Object.keys(counts || {}).forEach(function (k) {
|
||||
var b = document.querySelector('.draco-badge[data-badge="' + k + '"]');
|
||||
if (!b) return;
|
||||
b.textContent = counts[k];
|
||||
b.hidden = !(counts[k] > 0);
|
||||
});
|
||||
}
|
||||
function setCurrent(app) {
|
||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||
t.dataset.active = t.dataset.app === app ? "true" : "false";
|
||||
});
|
||||
}
|
||||
function setDomain(dom) {
|
||||
document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) {
|
||||
b.dataset.on = b.dataset.dom === dom ? "true" : "false";
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
var el = ev.target.closest("[data-act]");
|
||||
if (!el) return;
|
||||
var act = el.dataset.act;
|
||||
if (act === "domaine") setDomain(el.dataset.dom);
|
||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||
});
|
||||
|
||||
/* API publique : l'app met les compteurs à jour sans recharger. */
|
||||
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
||||
|
||||
if (cfg.current) setCurrent(cfg.current);
|
||||
if (cfg.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
})();
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Références croisées ``external_ref`` — Fusion ACC-1.3 (Flowdeck).
|
||||
|
||||
Vérifie le parseur d'URL profondes (contrat figé dracodev-commons), le
|
||||
cycle API sur une page et sur un projet (collection), les refus
|
||||
(422/404/401) et la route profonde ``/collections/{id}`` du contrat
|
||||
ACC-0.1 (redirection vers la vue native ``/db/{id}``).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.external_refs import parse_deep_link
|
||||
|
||||
# ── Parseur d'URL profondes ─────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("url,attendu", [
|
||||
("https://tiktak.dracodev.net/?task=42", ("tiktak", "task", "42")),
|
||||
("https://tiktak.dracodev.net/?list=3", ("tiktak", "list", "3")),
|
||||
("https://og.dracodev.net/#file=Perso:Notes/Idée.md",
|
||||
("obsigate", "note", "Perso:Notes/Idée.md")),
|
||||
("https://flowdeck.dracodev.net/pages/7", ("flowdeck", "page", "7")),
|
||||
("https://flowdeck.dracodev.net/collections/9", ("flowdeck", "project", "9")),
|
||||
("https://git.dracodev.net/Projets/ticktick-clone/issues/213",
|
||||
("kanbante", "card", "Projets/ticktick-clone#213")),
|
||||
])
|
||||
def test_parse_formes_reconnues(url, attendu):
|
||||
parsed = parse_deep_link(url)
|
||||
assert parsed is not None
|
||||
assert parsed[:3] == attendu
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://tiktak.dracodev.net/autre?x=1",
|
||||
"https://og.dracodev.net/#recherche",
|
||||
"https://flowdeck.dracodev.net/tableau",
|
||||
"https://example.com/pages/1",
|
||||
"pas une url",
|
||||
"",
|
||||
])
|
||||
def test_parse_formes_refusees(url):
|
||||
assert parse_deep_link(url) is None
|
||||
|
||||
|
||||
# ── Fixtures d'objets ───────────────────────────────────────────────────
|
||||
|
||||
def _creer_page() -> int:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title) VALUES ('bruno/flowdeck', 'Page Fusion')"
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _creer_collection() -> int:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO collections (name) VALUES ('Projet Fusion')")
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
# ── API sur une page ────────────────────────────────────────────────────
|
||||
|
||||
def test_cycle_refs_page(client):
|
||||
pid = _creer_page()
|
||||
r = client.post(f"/api/pages/{pid}/refs",
|
||||
json={"url": "https://tiktak.dracodev.net/?task=42"})
|
||||
assert r.status_code == 201, r.text
|
||||
ref = r.json()
|
||||
assert ref["target_app"] == "tiktak" and ref["target_id"] == "42"
|
||||
assert ref["label"] == "TikTak · tâche 42"
|
||||
assert ref["app_color"] == "#e8762c"
|
||||
|
||||
r = client.post(f"/api/pages/{pid}/refs",
|
||||
json={"url": "https://og.dracodev.net/#file=Perso:A.md",
|
||||
"label": "Note liée"})
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
r = client.get(f"/api/pages/{pid}/refs")
|
||||
assert r.status_code == 200
|
||||
assert [x["target_app"] for x in r.json()["refs"]] == ["tiktak", "obsigate"]
|
||||
assert r.json()["refs"][1]["label"] == "Note liée"
|
||||
|
||||
r = client.delete(f"/api/refs/{ref['id']}")
|
||||
assert r.status_code == 200
|
||||
r = client.get(f"/api/pages/{pid}/refs")
|
||||
assert [x["target_app"] for x in r.json()["refs"]] == ["obsigate"]
|
||||
|
||||
|
||||
def test_ref_url_inconnue_422(client):
|
||||
pid = _creer_page()
|
||||
r = client.post(f"/api/pages/{pid}/refs", json={"url": "https://example.com/x"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_delete_ref_inconnue_404(client):
|
||||
r = client.delete("/api/refs/999999")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_refs_anonyme_401(client):
|
||||
from tests.conftest import anon
|
||||
pid = _creer_page()
|
||||
r = anon(client).get(f"/api/pages/{pid}/refs")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ── API sur un projet (collection) + route profonde ─────────────────────
|
||||
|
||||
def test_cycle_refs_collection(client):
|
||||
cid = _creer_collection()
|
||||
r = client.post(f"/api/collections/{cid}/refs",
|
||||
json={"url": "https://git.dracodev.net/Projets/fusion/issues/5"})
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["target_app"] == "kanbante"
|
||||
r = client.get(f"/api/collections/{cid}/refs")
|
||||
assert len(r.json()["refs"]) == 1
|
||||
|
||||
|
||||
def test_route_profonde_collection(client):
|
||||
cid = _creer_collection()
|
||||
r = client.get(f"/collections/{cid}", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"] == f"/db/{cid}"
|
||||
r = client.get("/collections/999999", follow_redirects=False)
|
||||
assert r.status_code == 404
|
||||
Reference in New Issue
Block a user