Files
flowdeck/app/services/wiki.py
T
bruno 1706ad1ee9
FlowDeck CI / lint (push) Successful in 1m48s
FlowDeck CI / test (push) Failing after 21m19s
FlowDeck CI / docker (push) Skipped
feat: v7.3.0 — cycle v6.8.0→v7.3.0 (Sites, Search, Automations, Calendar, SCIM, Wiki) + audit A9
- v6.8.0 Sites & Forms publics (migrations 24)
- v6.9.0 Recherche sémantique hybride + Ask AI (migration 25)
- v7.0.0 Automations v2 multi-étapes + Workers sandboxés (migration 26)
- v7.1.0 Calendar sync Google/CalDAV + Meeting Notes (migration 27)
- v7.2.0 Enterprise : SCIM 2.0, 2FA TOTP/passkeys, audit UI, agent approvals (migration 28)
- v7.3.0 Wiki/Teamspaces, verified pages, collab polish, charts, unfurl (migration 29)
- docs V68→V73, ROADMAP/CHANGELOG/WORKLOAD à jour, VERSION 7.3.0
- A9 : flowdeck.db, flowdeck_dev.db, test-commit.md, upload_test.txt et e2e/{node_modules,shots,test-results} désindexés + ignorés (.gitignore/.dockerignore)
2026-09-30 20:02:57 -04:00

416 lines
17 KiB
Python

"""FlowDeck — teamspaces, verified pages, collab polish (v7.3.0).
Design : ``docs/V73_Wiki_Teamspaces_Polish.md``.
- ``teamspaces`` namespace pages + databases ; ``private=1`` → invisible aux
non-membres (404, comme une collection restricted).
- ``page_verifications`` : badge ✅ avec expiration (90 j par défaut).
- ``page_follows`` → notif ``page.updated`` ; ``comment_reactions`` ;
``guest_shares`` (``/g/<token>``) ; ``page_views`` (compteurs journaliers).
"""
from __future__ import annotations
import datetime
import secrets
from app.db import get_conn
VERIFICATION_DAYS_DEFAULT = 90
# Roles, strongest first. Mirrors collection roles.
TEAMSPACE_ROLES = ("owner", "editor", "commenter", "viewer")
_ROLE_RANK = {r: i for i, r in enumerate(reversed(TEAMSPACE_ROLES))}
def _utcnow() -> datetime.datetime:
return datetime.datetime.now(datetime.UTC)
def _iso(dt: datetime.datetime) -> str:
return dt.replace(microsecond=0).isoformat()
# ── teamspaces ─────────────────────────────────────────────────────────────
def get_teamspace_role(user_id: int | None, teamspace_id: int) -> str | None:
"""Explicit role, else workspace role, else ``None`` when unreachable."""
if not user_id:
return None
with get_conn() as conn:
ts = conn.execute("SELECT workspace_id, private FROM teamspaces WHERE id=?",
(teamspace_id,)).fetchone()
if not ts:
return None
row = conn.execute("SELECT role FROM teamspace_members WHERE teamspace_id=? AND user_id=?",
(teamspace_id, user_id)).fetchone()
if row:
return row["role"]
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
if admin and admin["is_admin"]:
return "owner"
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?",
(ts["workspace_id"],)).fetchone()
if owner and owner["owner_id"] == user_id:
return "owner"
if not ts["private"]:
# a public teamspace is still workspace-scoped: no workspace
# membership means no access (otherwise any logged-in account on
# the instance could read every public teamspace).
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(ts["workspace_id"], user_id)).fetchone()
return member["role"] if member else None
return None
def can_read_teamspace(user_id: int | None, teamspace_id: int) -> bool:
return get_teamspace_role(user_id, teamspace_id) is not None
def can_write_teamspace(user_id: int | None, teamspace_id: int) -> bool:
role = get_teamspace_role(user_id, teamspace_id)
return role in ("owner", "editor")
def list_teamspaces(user_id: int, workspace_id: int | None = None) -> list[dict]:
"""Teamspaces the user can see (private ones filtered out).
``workspace_id=None`` lists across every workspace (global sidebar).
"""
with get_conn() as conn:
if workspace_id is not None:
ws_member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(workspace_id, user_id)).fetchone()
if not ws_member:
owner = conn.execute("SELECT owner_id FROM workspaces WHERE id=?",
(workspace_id,)).fetchone()
admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
if not owner or owner["owner_id"] != user_id:
if not (admin and admin["is_admin"]):
return []
rows = conn.execute("SELECT * FROM teamspaces WHERE workspace_id=? ORDER BY name",
(workspace_id,)).fetchall()
else:
rows = conn.execute("SELECT * FROM teamspaces ORDER BY workspace_id, name").fetchall()
out = []
for r in rows:
role = get_teamspace_role(user_id, r["id"])
if role is None:
continue
counts = conn.execute(
"""SELECT (SELECT COUNT(*) FROM pages WHERE teamspace_id=?)
+ (SELECT COUNT(*) FROM collections WHERE teamspace_id=?) AS n""",
(r["id"], r["id"])).fetchone()
item = dict(r)
item["role"] = role
item["workspace_name"] = _workspace_name(conn, r["workspace_id"])
item["item_count"] = counts["n"]
out.append(item)
return out
def _workspace_name(conn, workspace_id: int) -> str:
row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
return row["name"] if row else ""
def teamspace_pages(teamspace_id: int) -> list[dict]:
"""Non-deleted pages belonging to a teamspace (title/URL order)."""
with get_conn() as conn:
return [dict(r) for r in conn.execute(
"""SELECT id, title, workspace_id, teamspace_id
FROM pages WHERE teamspace_id=? AND deleted_at IS NULL
ORDER BY title""", (teamspace_id,)).fetchall()]
def teamspace_collections(teamspace_id: int) -> list[dict]:
"""Databases belonging to a teamspace."""
with get_conn() as conn:
return [dict(r) for r in conn.execute(
"""SELECT id, name, icon FROM collections
WHERE teamspace_id=? ORDER BY name""", (teamspace_id,)).fetchall()]
def create_teamspace(workspace_id: int, name: str, user_id: int,
description: str = "", private: bool = False) -> int:
with get_conn() as conn:
try:
cur = conn.execute(
"INSERT INTO teamspaces (workspace_id, name, description, private, created_by)"
" VALUES (?,?,?,?,?)",
(workspace_id, name.strip(), description[:400], 1 if private else 0, user_id))
except Exception as exc: # UNIQUE(workspace_id, name)
if "UNIQUE" in str(exc):
raise ValueError("A teamspace with this name already exists") from None
raise
conn.commit()
# the creator is owner
conn.execute("INSERT INTO teamspace_members (teamspace_id, user_id, role)"
" VALUES (?,?,'owner')", (cur.lastrowid, user_id))
conn.commit()
return cur.lastrowid
def teamspace_member_ids(teamspace_id: int) -> list[int]:
with get_conn() as conn:
return [r["user_id"] for r in conn.execute(
"SELECT user_id FROM teamspace_members WHERE teamspace_id=?",
(teamspace_id,)).fetchall()]
# ── verified pages ─────────────────────────────────────────────────────────
def is_expired(row) -> bool:
if not row or not row["expires_at"]:
return False
try:
return _utcnow() > datetime.datetime.fromisoformat(row["expires_at"])
except ValueError:
return False
def verify_page(page_id: int, user_id: int, days: int = VERIFICATION_DAYS_DEFAULT,
note: str = "") -> dict:
days = max(1, min(int(days or VERIFICATION_DAYS_DEFAULT), 365))
expires = _iso(_utcnow() + datetime.timedelta(days=days))
with get_conn() as conn:
conn.execute(
"""INSERT INTO page_verifications (page_id, verified_by, note, expires_at)
VALUES (?,?,?,?)
ON CONFLICT(page_id) DO UPDATE SET
verified_by=excluded.verified_by, note=excluded.note,
verified_at=CURRENT_TIMESTAMP, expires_at=excluded.expires_at""",
(page_id, user_id, note[:400], expires))
conn.commit()
return verification(page_id)
def verification(page_id: int) -> dict | None:
with get_conn() as conn:
row = conn.execute(
"""SELECT v.*, u.full_name, u.login FROM page_verifications v
LEFT JOIN users u ON u.id = v.verified_by WHERE v.page_id=?""",
(page_id,)).fetchone()
if not row:
return None
d = dict(row)
d["expired"] = is_expired(row)
d["active"] = not d["expired"]
return d
def unverify_page(page_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute("DELETE FROM page_verifications WHERE page_id=?", (page_id,))
conn.commit()
return bool(cur.rowcount)
def expiring_verifications(days: int = 7) -> list[dict]:
"""Verifications expiring within ``days`` (drives the owner notification).
``pages`` has no owner column in FlowDeck, so the reminder targets the user
who performed the verification.
"""
horizon = _iso(_utcnow() + datetime.timedelta(days=days))
with get_conn() as conn:
rows = conn.execute(
"""SELECT v.*, p.title, v.verified_by AS owner_id FROM page_verifications v
JOIN pages p ON p.id = v.page_id
WHERE v.expires_at IS NOT NULL AND v.expires_at <= ?""",
(horizon,)).fetchall()
return [dict(r) for r in rows]
# ── follows ────────────────────────────────────────────────────────────────
def is_following(page_id: int, user_id: int) -> bool:
with get_conn() as conn:
return bool(conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone())
def toggle_follow(page_id: int, user_id: int) -> bool:
"""Returns the new state (True = now following)."""
with get_conn() as conn:
if conn.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone():
conn.execute("DELETE FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id))
conn.commit()
return False
conn.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)",
(page_id, user_id))
conn.commit()
return True
def ensure_follow(page_id: int, user_id: int, conn=None) -> bool:
"""Follow unless already following. Returns True when newly followed."""
if not user_id:
return False
def _run(c):
if c.execute("SELECT 1 FROM page_follows WHERE page_id=? AND user_id=?",
(page_id, user_id)).fetchone():
return False
c.execute("INSERT OR IGNORE INTO page_follows (page_id, user_id) VALUES (?,?)",
(page_id, user_id))
return True
if conn is not None:
added = _run(conn)
conn.commit()
return added
with get_conn() as _c:
added = _run(_c)
_c.commit()
return added
def followers(page_id: int) -> list[int]:
with get_conn() as conn:
return [r["user_id"] for r in conn.execute(
"SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()]
# Rate-limit window for ``page.updated`` notifications: the block editor
# autosaves every ~1.5 s; without it followers would be spammed per keystroke.
_UPDATE_NOTIF_WINDOW_MIN = 10
def notify_followers_of_page_update(page_id: int, actor_id: int | None,
title: str = "") -> int:
"""Create a ``page.updated`` notification for every follower (except the
actor), throttled to one per :data:`_UPDATE_NOTIF_WINDOW_MIN` minutes.
Returns the number of notifications created."""
if not page_id:
return 0
from app.services.notifications import create_notification
with get_conn() as conn:
followed = conn.execute("SELECT user_id FROM page_follows WHERE page_id=?", (page_id,)).fetchall()
if not followed:
return 0
page = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
title = (title or (page["title"] if page else "") or "Untitled")
created = 0
for f in followed:
uid = f["user_id"]
if uid == actor_id:
continue
recent = conn.execute(
"""SELECT 1 FROM notifications
WHERE user_id=? AND resource_type='page' AND resource_id=?
AND ntype='page.updated'
AND created_at >= datetime('now', ?)""",
(uid, page_id, f"-{_UPDATE_NOTIF_WINDOW_MIN} minutes")).fetchone()
if recent:
continue
create_notification(
uid, actor_id, "page.updated",
title=f'"{title}" was updated',
message=f'Page "{title}" has been modified',
resource_type="page", resource_id=page_id,
url=f"/pages/{page_id}", conn=conn, commit=False,
)
created += 1
conn.commit()
return created
# ── comment reactions ──────────────────────────────────────────────────────
def toggle_reaction(comment_id: int, user_id: int, emoji: str) -> dict:
"""Add or remove ``emoji``; returns the aggregated counts for the comment."""
emoji = (emoji or "").strip()[:16]
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM comments WHERE id=?", (comment_id,)).fetchone():
raise LookupError("Comment not found")
existing = conn.execute(
"SELECT id FROM comment_reactions WHERE comment_id=? AND user_id=? AND emoji=?",
(comment_id, user_id, emoji)).fetchone()
if existing:
conn.execute("DELETE FROM comment_reactions WHERE id=?", (existing["id"],))
conn.commit()
else:
conn.execute("INSERT INTO comment_reactions (comment_id, user_id, emoji)"
" VALUES (?,?,?)", (comment_id, user_id, emoji))
conn.commit()
return reactions(comment_id)
def reactions(comment_id: int) -> dict[str, dict]:
with get_conn() as conn:
rows = conn.execute(
"""SELECT emoji, COUNT(*) AS n,
GROUP_CONCAT(user_id) AS users
FROM comment_reactions WHERE comment_id=? GROUP BY emoji ORDER BY emoji""",
(comment_id,)).fetchall()
return {r["emoji"]: {"count": r["n"],
"users": [int(u) for u in (r["users"] or "").split(",") if u]}
for r in rows}
# ── guest shares ───────────────────────────────────────────────────────────
def create_guest_share(page_id: int, email: str, role: str, created_by: int,
days: int | None = 30) -> dict:
if role not in ("viewer", "commenter"):
raise ValueError("role must be viewer or commenter")
token = f"g_{secrets.token_urlsafe(24)}"
expires = _iso(_utcnow() + datetime.timedelta(days=days)) if days else None
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO guest_shares (page_id, email, token, role, created_by, expires_at)
VALUES (?,?,?,?,?,?)""",
(page_id, email[:200], token, role, created_by, expires))
conn.commit()
return dict(conn.execute("SELECT * FROM guest_shares WHERE id=?",
(cur.lastrowid,)).fetchone())
def resolve_guest_share(token: str) -> dict | None:
"""Active share for ``token``, or ``None`` (unknown / revoked / expired)."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM guest_shares WHERE token=?", (token,)).fetchone()
if not row or row["revoked"]:
return None
if row["expires_at"]:
try:
if _utcnow() > datetime.datetime.fromisoformat(row["expires_at"]):
return None
except ValueError:
pass
return dict(row)
# ── page views ─────────────────────────────────────────────────────────────
def record_view(page_id: int, day: str | None = None) -> int:
day = day or _utcnow().strftime("%Y-%m-%d")
with get_conn() as conn:
conn.execute(
"""INSERT INTO page_views (page_id, day, views) VALUES (?,?,1)
ON CONFLICT(page_id, day) DO UPDATE SET views = views + 1""",
(page_id, day))
conn.commit()
row = conn.execute("SELECT views FROM page_views WHERE page_id=? AND day=?",
(page_id, day)).fetchone()
return row["views"]
def view_stats(page_id: int, days: int = 30) -> dict:
days = max(1, min(int(days or 30), 365))
since = (_utcnow() - datetime.timedelta(days=days - 1)).strftime("%Y-%m-%d")
with get_conn() as conn:
rows = conn.execute(
"SELECT day, views FROM page_views WHERE page_id=? AND day>=? ORDER BY day",
(page_id, since)).fetchall()
series = {r["day"]: r["views"] for r in rows}
# fill the gap so charts have no holes
out, cursor = [], _utcnow() - datetime.timedelta(days=days - 1)
for _ in range(days):
key = cursor.strftime("%Y-%m-%d")
out.append({"day": key, "views": series.get(key, 0)})
cursor += datetime.timedelta(days=1)
return {"page_id": page_id, "days": days, "total": sum(series.values()),
"series": out}