- 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)
416 lines
17 KiB
Python
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}
|