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)
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user