feat: tags personnalisés par utilisateur (user_id)
DB: - tags.user_id REFERENCES users(id) - UNIQUE(name, user_id) au lieu de UNIQUE(name) - Migration v2.6: alter + recreate table API (tous les endpoints tags): - POST/PUT/DELETE /api/settings/tags: filtré par user_id - GET /api/settings/tags/all: retourne uniquement les tags du user - GET/POST /api/local-workspace/*/tags: scope user → Les tags de l'utilisateur 1 ne sont pas visibles par l'utilisateur 2
This commit is contained in:
@@ -306,9 +306,11 @@ def init_db():
|
||||
-- v2.2.0: Tags system
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#787774',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(name, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS page_tags (
|
||||
@@ -379,6 +381,19 @@ def init_db():
|
||||
)""")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v2.6: Tags per-user
|
||||
try:
|
||||
conn.execute("ALTER TABLE tags ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id)")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
# Recreate UNIQUE constraint for per-user tags
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS tags_new (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, color TEXT DEFAULT '#787774', user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(name, user_id))")
|
||||
conn.execute("INSERT OR IGNORE INTO tags_new (id, name, color, user_id, created_at) SELECT id, name, color, COALESCE(user_id,0), created_at FROM tags")
|
||||
conn.execute("DROP TABLE tags")
|
||||
conn.execute("ALTER TABLE tags_new RENAME TO tags")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
+29
-23
@@ -1182,82 +1182,87 @@ async def set_avatar_color(request: Request):
|
||||
return {"status": "ok", "color": color}
|
||||
|
||||
|
||||
# ═══════════ Tag Management API (global) ═══════════
|
||||
# ═══════════ Tag Management API (per-user) ═══════════
|
||||
|
||||
@router.post("/api/settings/tags")
|
||||
async def create_tag_global(request: Request):
|
||||
"""Create a tag with optional color."""
|
||||
"""Create a tag for the current user."""
|
||||
import json
|
||||
body = await request.json()
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
color = body.get("color", "#787774")
|
||||
if not tag_name:
|
||||
uid = _get_user_id(request)
|
||||
if not tag_name or not uid:
|
||||
return {"error": "Tag name required"}, 400
|
||||
with get_conn() as conn:
|
||||
tag = conn.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)).fetchone()
|
||||
tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
||||
if tag:
|
||||
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"]))
|
||||
conn.commit()
|
||||
return {"tag": {"id": tag["id"], "name": tag_name, "color": color}}
|
||||
cursor = conn.execute("INSERT INTO tags (name, color) VALUES (?, ?)", (tag_name, color))
|
||||
cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid))
|
||||
conn.commit()
|
||||
return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}}
|
||||
|
||||
|
||||
@router.put("/api/settings/tags/{tag_id:int}")
|
||||
async def update_tag_global(tag_id: int, request: Request):
|
||||
"""Update a tag (name or color)."""
|
||||
"""Update a tag (name or color) — only if owned by user."""
|
||||
import json
|
||||
body = await request.json()
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
if "name" in body:
|
||||
conn.execute("UPDATE tags SET name = ? WHERE id = ?", (body["name"].strip().lower(), tag_id))
|
||||
conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid))
|
||||
if "color" in body:
|
||||
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (body["color"], tag_id))
|
||||
conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/settings/tags/{tag_id:int}")
|
||||
async def delete_tag_global(tag_id: int):
|
||||
"""Delete a tag and its page associations."""
|
||||
async def delete_tag_global(tag_id: int, request: Request):
|
||||
"""Delete a tag — only if owned by user."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,))
|
||||
conn.execute("DELETE FROM tags WHERE id = ?", (tag_id,))
|
||||
conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/api/settings/tags/all")
|
||||
async def list_all_tags_global():
|
||||
"""List all tags with counts."""
|
||||
async def list_all_tags_global(request: Request):
|
||||
"""List current user's tags with counts."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count "
|
||||
"FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id "
|
||||
"GROUP BY t.id ORDER BY t.name"
|
||||
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return {"tags": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ═══════════ Tags API ═══════════
|
||||
# ═══════════ Workspace Tags API ═══════════
|
||||
|
||||
@router.get("/api/local-workspace/tags")
|
||||
async def list_tags(request: Request):
|
||||
"""List all tags used in the active workspace."""
|
||||
"""List tags used in the active workspace (current user's tags only)."""
|
||||
ws = _get_active_workspace(request)
|
||||
ws_id = ws["id"] if ws else None
|
||||
uid = _get_user_id(request)
|
||||
if not ws_id:
|
||||
return {"tags": []}
|
||||
import json
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT t.id, t.name, t.color, COUNT(pt.page_id) as count "
|
||||
"FROM tags t "
|
||||
"JOIN page_tags pt ON pt.tag_id = t.id "
|
||||
"JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
|
||||
"GROUP BY t.id ORDER BY t.name",
|
||||
(ws_id,),
|
||||
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
||||
(ws_id, uid),
|
||||
).fetchall()
|
||||
return {"tags": [dict(r) for r in rows]}
|
||||
|
||||
@@ -1277,20 +1282,21 @@ async def get_item_tags(item_id: int):
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/tags")
|
||||
async def add_item_tag(request: Request, item_id: int):
|
||||
"""Add a tag to an item (creates tag if new)."""
|
||||
"""Add a tag to an item (creates tag if new, scoped to user)."""
|
||||
import json
|
||||
body = await request.json()
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
tag_color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
if not tag_name:
|
||||
return {"error": "Tag name required"}, 400
|
||||
|
||||
with get_conn() as conn:
|
||||
# Get or create tag
|
||||
tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ?", (tag_name,)).fetchone()
|
||||
# Get or create tag (per user)
|
||||
tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
|
||||
if not tag:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO tags (name, color) VALUES (?, ?)", (tag_name, tag_color)
|
||||
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid)
|
||||
)
|
||||
conn.commit()
|
||||
tag_id = cursor.lastrowid
|
||||
|
||||
Reference in New Issue
Block a user