fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
+374
-98
@@ -1,147 +1,423 @@
|
||||
"""FlowDeck — My Tasks router (v1.9.0)."""
|
||||
"""FlowDeck — My Tasks (tableau de bord de tâches façon Notion).
|
||||
|
||||
Contrairement à Notion, où *My Tasks* vit dans l'onglet **Home** sans être
|
||||
rattaché à une page, cette page est un **tableau de bord transverse** : elle
|
||||
réunit toutes les tâches qui vous sont assignées, **tous workspaces
|
||||
confondus**. Le cookie `flowdeck_workspace` ne cadre donc plus cette vue (il
|
||||
reste utilisé par la sidebar).
|
||||
|
||||
Les sources sont opt-in : une base n'alimente My Tasks qu'après conversion
|
||||
explicite (`collections.is_task` + mapping des trois propriétés requises —
|
||||
`app/services/task_databases.py`).
|
||||
|
||||
Trois vues (tableau / kanban / calendrier), filtres globaux (échéance
|
||||
aujourd'hui, masquer les terminées), tri (échéance, statut, source), édition
|
||||
en direct du statut et de l'échéance, et création rapide dans n'importe
|
||||
quelle base connectée.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.property_types import apply_auto_properties
|
||||
from app.services.task_databases import (
|
||||
MAX_TASK_SOURCES,
|
||||
collect_tasks,
|
||||
filter_and_sort,
|
||||
list_task_sources,
|
||||
user_today,
|
||||
)
|
||||
from app.templating import ENV
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||
|
||||
VIEWS = ("table", "board", "calendar")
|
||||
DUE_FILTERS = ("all", "today", "overdue", "week")
|
||||
SORTS = ("due", "status", "source", "created")
|
||||
|
||||
|
||||
# ── Session ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_current_user(request: Request) -> dict | None:
|
||||
session = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(session)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||
def _require_user(request: Request) -> dict:
|
||||
"""Session obligatoire, sans repli « admin » (id 1) : ce tableau de bord
|
||||
est strictement celui de l'appelant."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
|
||||
for col in collections:
|
||||
col_id = col["id"]
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col_id,),
|
||||
).fetchall()
|
||||
# ── Données ──────────────────────────────────────────────────────────────
|
||||
|
||||
collection_tasks = []
|
||||
for p in pages:
|
||||
props = {}
|
||||
try:
|
||||
props = json.loads(p["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
due_date = props.get("due_date", "")
|
||||
status = props.get("status", "—")
|
||||
assignee = props.get("assignee", "")
|
||||
if view != "all" and assignee and assignee != user_login:
|
||||
continue
|
||||
collection_tasks.append({
|
||||
"id": p["id"],
|
||||
"title": p["title"] or "Untitled",
|
||||
"icon": p["icon"] or "📋",
|
||||
"status": status,
|
||||
"due_date": due_date,
|
||||
})
|
||||
if collection_tasks:
|
||||
grouped[col["name"]] = collection_tasks
|
||||
def _workspace_names(conn, user_id: int) -> dict[int, str]:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ?", (user_id,)
|
||||
).fetchall()
|
||||
return {r["id"]: r["name"] for r in rows}
|
||||
|
||||
total = sum(len(t) for t in grouped.values())
|
||||
|
||||
# Build content HTML
|
||||
sections = ""
|
||||
for col_name, tasks in grouped.items():
|
||||
items = ""
|
||||
for t in tasks:
|
||||
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
|
||||
status_cls = t['status'].lower().replace(' ', '-') if t.get('status') else 'none'
|
||||
status_badge = f"<span class='mt-status mt-{status_cls}'>{t.get('status', '—')}</span>"
|
||||
items += f"<div class='mt-item'><span class='mt-icon'>{t['icon']}</span><span class='mt-title'>{t['title']}</span>{status_badge}{due_badge}</div>"
|
||||
def _validated(view: str, due: str, sort: str) -> tuple[str, str, str]:
|
||||
"""Paramètres d'URL bornés aux valeurs réellement implémentées."""
|
||||
return (
|
||||
view if view in VIEWS else "table",
|
||||
due if due in DUE_FILTERS else "all",
|
||||
sort if sort in SORTS else "due",
|
||||
)
|
||||
|
||||
sections += f"<div class='mt-section'><div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>{items}</div>"
|
||||
|
||||
content_html = f"""<div style="max-width:800px;margin:0 auto;padding:40px 24px;">
|
||||
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
|
||||
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
|
||||
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
|
||||
<a class="tab{' active' if view=='all' else ''}" href="/my-tasks?view=all" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">All</a>
|
||||
<a class="tab{' active' if view=='today' else ''}" href="/my-tasks?view=today" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Today</a>
|
||||
<a class="tab{' active' if view=='overdue' else ''}" href="/my-tasks?view=overdue" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Overdue</a>
|
||||
<a class="tab{' active' if view=='upcoming' else ''}" href="/my-tasks?view=upcoming&days=7" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Next 7 days</a>
|
||||
</div>
|
||||
<style>
|
||||
.tab.active{{background:var(--accent);color:#fff!important;}}
|
||||
.tab:hover{{background:var(--bg-hover);color:var(--text);}}
|
||||
.mt-section{{margin-bottom:24px;}}
|
||||
.mt-section-header{{font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;}}
|
||||
.mt-count{{color:var(--text-dim);font-weight:400;opacity:.5;}}
|
||||
.mt-item{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:8px;margin-bottom:3px;border:1px solid var(--border);}}
|
||||
.mt-item:hover{{border-color:var(--accent);}}
|
||||
.mt-icon{{font-size:16px;}}
|
||||
.mt-title{{flex:1;font-size:14px;}}
|
||||
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px;}}
|
||||
.mt-due{{font-size:11px;color:var(--text-dim);}}
|
||||
.mt-todo,.mt-none{{background:rgba(255,255,255,.05);color:var(--text-dim);}}
|
||||
.mt-in-progress{{background:rgba(35,131,226,.15);color:#2C8CEB;}}
|
||||
.mt-done,.mt-completed,.mt-complete{{background:rgba(0,200,100,.15);color:#00CC66;}}
|
||||
</style>
|
||||
{sections}
|
||||
</div>"""
|
||||
def _public_source(s: dict) -> dict:
|
||||
return {
|
||||
"id": s["id"],
|
||||
"name": s["name"],
|
||||
"icon": s["icon"],
|
||||
"workspace_id": s["workspace_id"],
|
||||
"workspace_name": s.get("workspace_name") or "",
|
||||
"configured": s["configured"],
|
||||
"missing_roles": s["missing_roles"],
|
||||
"status_options": s["status_options"],
|
||||
}
|
||||
|
||||
|
||||
def _payload(conn, request: Request, user_id: int, *, due: str, sort: str,
|
||||
hide_done: bool) -> dict:
|
||||
"""Charge et normalise tout ce dont les trois vues ont besoin."""
|
||||
user = _get_current_user(request) or {}
|
||||
tasks = collect_tasks(conn, user_id, mine_only=True,
|
||||
login=user.get("login", ""))
|
||||
# Même jour de référence que `due_state` (fuseau de l'utilisateur).
|
||||
tasks = filter_and_sort(tasks, due=due, hide_done=hide_done, sort=sort,
|
||||
today=user_today(conn, user_id))
|
||||
sources = list_task_sources(conn, user_id)
|
||||
return {
|
||||
"tasks": tasks,
|
||||
"total": len(tasks),
|
||||
"sources": [_public_source(s) for s in sources],
|
||||
"sources_total": len(sources),
|
||||
"sources_configured": sum(1 for s in sources if s["configured"]),
|
||||
"max_sources": MAX_TASK_SOURCES,
|
||||
"filters": {"due": due, "sort": sort, "hide_done": hide_done},
|
||||
}
|
||||
|
||||
|
||||
# ── Rendu HTML ───────────────────────────────────────────────────────────
|
||||
|
||||
def _render_shell(request: Request, content_html: str, *, app_js: bool = True):
|
||||
"""Enveloppe le contenu dans le layout commun (sidebar + base.html).
|
||||
|
||||
`static/css/my_tasks.css` est lié par `base.html` (le shell, jamais swappé)
|
||||
: un `<link>` placé dans la zone swappée pouvait être retiré par htmx lors
|
||||
d'une navigation partielle, et la page revenait sans aucun style. Seul
|
||||
`static/js/my_tasks.js` reste conditionné à ``app_js`` : sans base
|
||||
connectée, il n'y a rien à monter côté client.
|
||||
"""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = ENV
|
||||
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
# `assets` est rendu par un mini-template : `asset_version` (cache-busting)
|
||||
# n'est pas substitutionné si on le concatène à la main.
|
||||
assets = ENV.from_string(
|
||||
"{% if app_js %}"
|
||||
'<script src="/static/js/my_tasks.js?v={{ asset_version }}" defer></script>'
|
||||
"{% endif %}"
|
||||
).render(app_js=app_js)
|
||||
|
||||
block_tpl = ENV.from_string(
|
||||
'{% extends "base.html" %}'
|
||||
"{% block content %}{{ assets|safe }}{{ content_html|safe }}{% endblock %}"
|
||||
)
|
||||
return block_tpl.render(
|
||||
**sidebar,
|
||||
request=request,
|
||||
content_html=content_html,
|
||||
assets=assets,
|
||||
page_title="My Tasks",
|
||||
title_prefix="My Tasks",
|
||||
page_icon="📋",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
def _json_attr(obj) -> str:
|
||||
"""JSON échappé pour un attribut HTML."""
|
||||
return html.escape(json.dumps(obj, ensure_ascii=False), quote=True)
|
||||
|
||||
|
||||
def _render_app(data: dict, view: str) -> str:
|
||||
"""Coquille du tableau de bord : barre d'outils, filtres et zone de vue
|
||||
sont rendus côté client à partir de la configuration ci-dessous."""
|
||||
config = {
|
||||
"view": view,
|
||||
"filters": data["filters"],
|
||||
"sources": data["sources"],
|
||||
"maxSources": data["max_sources"],
|
||||
"views": list(VIEWS),
|
||||
}
|
||||
return ('<div id="my-tasks-app" class="my-tasks-app" '
|
||||
f'data-config="{_json_attr(config)}"></div>')
|
||||
|
||||
|
||||
def _render_empty(data: dict) -> str:
|
||||
"""État vide « aucune source » : explique la conversion en base de tâches.
|
||||
|
||||
Gabarit centré (`my_tasks-empty*` dans `static/css/my_tasks.css`), sans
|
||||
style inline : la mise en page suit le thème clair/sombre et reste
|
||||
modifiable en un seul endroit. Les autres états vides (filtres actifs,
|
||||
rien à faire) sont rendus par le client, qui connaît l'état des filtres.
|
||||
"""
|
||||
return f"""<div class="my-tasks-empty">
|
||||
<div class="my-tasks-empty-icon">📋</div>
|
||||
<h2>My Tasks</h2>
|
||||
<p>Aucune base n'alimente encore ce tableau de bord. Pour en ajouter une :</p>
|
||||
<ol class="my-tasks-empty-steps">
|
||||
<li><span class="n">1</span><span>Ouvrez une base de données depuis
|
||||
<a href="/db">la liste de vos bases</a>.</span></li>
|
||||
<li><span class="n">2</span><span>Dans la barre de la base, cliquez sur
|
||||
<b>« Convertir en base de tâches »</b> — il se trouve à gauche du bouton
|
||||
<b>New</b>.</span></li>
|
||||
<li><span class="n">3</span><span>Reliez les colonnes <b>Assigné à</b>,
|
||||
<b>Statut</b> et <b>Échéance</b>, puis validez.</span></li>
|
||||
</ol>
|
||||
<p class="my-tasks-empty-hint">Jusqu'à {data["max_sources"]} bases
|
||||
peuvent alimenter ce tableau de bord.</p>
|
||||
<a href="/db" class="my-tasks-empty-btn">Ouvrir mes bases</a>
|
||||
</div>"""
|
||||
|
||||
|
||||
def _render_no_workspace() -> str:
|
||||
return """<div class="my-tasks-empty">
|
||||
<div class="my-tasks-empty-icon">📋</div>
|
||||
<h2>My Tasks</h2>
|
||||
<p>Aucun workspace. Créez un workspace, puis une base de tâches : elle
|
||||
alimentera ce tableau de bord.</p>
|
||||
<a href="/workspaces" class="my-tasks-empty-btn">Créer un workspace</a>
|
||||
</div>"""
|
||||
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def my_tasks_dashboard(request: Request, view: str = "table", due: str = "all",
|
||||
sort: str = "due", hide_done: bool = False):
|
||||
"""My Tasks — toutes vos tâches, tous workspaces confondus."""
|
||||
user = _get_current_user(request)
|
||||
user.get("login", "admin") if user else "admin"
|
||||
if not user or not user.get("id"):
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
view, due, sort = _validated(view, due, sort)
|
||||
with get_conn() as conn:
|
||||
if not _workspace_names(conn, user["id"]):
|
||||
return _render_shell(request, _render_no_workspace(), app_js=False)
|
||||
data = _payload(conn, request, user["id"], due=due, sort=sort,
|
||||
hide_done=hide_done)
|
||||
|
||||
# Dès qu'au moins une base alimente le tableau de bord, le rendu est pris
|
||||
# en charge par le client (3 vues + états vides contextualisés). Sans
|
||||
# aucune source, le serveur affiche l'invite à convertir une base — le
|
||||
# fichier JS n'est alors pas chargé.
|
||||
if data["total"] or data["sources_total"]:
|
||||
return _render_shell(request, _render_app(data, view))
|
||||
return _render_shell(request, _render_empty(data), app_js=False)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def my_tasks_api(request: Request, view: str = "table", due: str = "all",
|
||||
sort: str = "due", hide_done: bool = False):
|
||||
"""API: tâches normalisées + sources connectées (alimente les 3 vues)."""
|
||||
user = _require_user(request)
|
||||
view, due, sort = _validated(view, due, sort)
|
||||
with get_conn() as conn:
|
||||
return _payload(conn, request, user["id"], due=due, sort=sort,
|
||||
hide_done=hide_done)
|
||||
|
||||
|
||||
@router.post("/api/task")
|
||||
def my_tasks_create(request: Request, body: dict = Body(default={})):
|
||||
"""Création rapide multi-destinations : la tâche part dans la base choisie.
|
||||
|
||||
Elle est **auto-assignée** : sans colonne « Assigné à » renseignée, une
|
||||
tâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait
|
||||
illisible pour l'utilisateur qui vient de la créer.
|
||||
"""
|
||||
user = _require_user(request)
|
||||
collection_id = body.get("collection_id")
|
||||
title = str(body.get("title") or "").strip()
|
||||
if not collection_id:
|
||||
raise HTTPException(status_code=400, detail="collection_id is required")
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
result = {}
|
||||
coll = conn.execute(
|
||||
"SELECT id, task_assignee_prop, task_due_prop FROM collections "
|
||||
"WHERE id=? AND is_task=1",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if coll is None:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="This database does not feed My Tasks")
|
||||
|
||||
for col in collections:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col["id"],),
|
||||
props_meta = [
|
||||
dict(p) for p in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties "
|
||||
"WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
tasks = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
tasks.append({
|
||||
"id": p["id"], "title": p["title"], "icon": p["icon"],
|
||||
"properties": props,
|
||||
})
|
||||
properties: dict = {}
|
||||
title_prop = next((p for p in props_meta if p["prop_type"] == "title"), None)
|
||||
if title_prop:
|
||||
properties[str(title_prop["id"])] = title
|
||||
|
||||
if tasks:
|
||||
result[col["name"]] = tasks
|
||||
if body.get("due") and coll["task_due_prop"]:
|
||||
properties[str(coll["task_due_prop"])] = str(body["due"])[:10]
|
||||
|
||||
return {"tasks": result, "total": sum(len(t) for t in result.values())}
|
||||
if coll["task_assignee_prop"]:
|
||||
member = conn.execute(
|
||||
"SELECT id, login, full_name, avatar_url, avatar_color "
|
||||
"FROM users WHERE id=?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if member:
|
||||
properties[str(coll["task_assignee_prop"])] = [{
|
||||
"id": member["id"],
|
||||
"login": member["login"],
|
||||
"full_name": member["full_name"],
|
||||
"avatar_url": member["avatar_url"] or "",
|
||||
"avatar_color": member["avatar_color"] or "",
|
||||
}]
|
||||
|
||||
apply_auto_properties(props_meta, properties, user, is_create=True)
|
||||
|
||||
nxt = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages "
|
||||
"WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, position, property_values_json)
|
||||
VALUES (?, ?, '📄', ?, ?)""",
|
||||
(collection_id, title, nxt, json.dumps(properties)),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
if title_prop:
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
logger.info("my-tasks: task %s created in collection %s", page_id, collection_id)
|
||||
return {"status": "created", "id": page_id, "collection_id": collection_id,
|
||||
"title": title}
|
||||
|
||||
|
||||
@router.put("/api/task/{page_id}")
|
||||
def my_tasks_update(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Édition en direct du statut / de l'échéance / du titre.
|
||||
|
||||
L'écriture passe par le **mapping enregistré de la base source** (et non
|
||||
par une devinette « première colonne de type X »), puis la tâche est relue
|
||||
et renvoyée normalisée : le front n'a rien à recalculer.
|
||||
"""
|
||||
user = _require_user(request)
|
||||
field = body.get("field")
|
||||
if field not in ("status", "due", "title", "assignee"):
|
||||
raise HTTPException(status_code=400, detail="Unsupported field")
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, collection_id, title, property_values_json "
|
||||
"FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
coll = conn.execute(
|
||||
"SELECT id, is_task, task_assignee_prop, task_status_prop, task_due_prop "
|
||||
"FROM collections WHERE id=?",
|
||||
(row["collection_id"],),
|
||||
).fetchone()
|
||||
if coll is None:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
if not coll["is_task"]:
|
||||
raise HTTPException(status_code=400,
|
||||
detail="This database no longer feeds My Tasks")
|
||||
|
||||
column = {"status": coll["task_status_prop"],
|
||||
"due": coll["task_due_prop"],
|
||||
"assignee": coll["task_assignee_prop"]}.get(field)
|
||||
if field != "title" and not column:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"This database has no {field} column")
|
||||
|
||||
try:
|
||||
pv = json.loads(row["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pv = {}
|
||||
if not isinstance(pv, dict):
|
||||
pv = {}
|
||||
|
||||
new_title = None
|
||||
if field == "title":
|
||||
new_title = str(body.get("value") or "").strip()
|
||||
title_prop = conn.execute(
|
||||
"SELECT id FROM collection_properties "
|
||||
"WHERE collection_id=? AND prop_type='title' ORDER BY position LIMIT 1",
|
||||
(coll["id"],),
|
||||
).fetchone()
|
||||
if title_prop:
|
||||
pv[str(title_prop["id"])] = new_title
|
||||
elif field == "assignee":
|
||||
# La valeur est une liste de personnes : elle est écrite telle
|
||||
# quelle (booleenner écraserait la colonne).
|
||||
pv[str(column)] = body.get("value") or []
|
||||
elif field == "due":
|
||||
pv[str(column)] = str(body.get("value") or "")[:10]
|
||||
else:
|
||||
pv[str(column)] = str(body.get("value") or "")
|
||||
|
||||
if new_title is not None:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET title=?, property_values_json=?, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(new_title, json.dumps(pv), page_id),
|
||||
)
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(pv), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
tasks = collect_tasks(conn, user["id"], mine_only=True,
|
||||
login=user.get("login", ""))
|
||||
updated = next((t for t in tasks if t["id"] == page_id), None)
|
||||
|
||||
if updated is None:
|
||||
# La tâche vient de cesser d'être « mienne » (désassignée) : le front
|
||||
# doit la retirer, pas la re-rendre à l'identique.
|
||||
return {"status": "updated", "task": None, "left_mytasks": True}
|
||||
|
||||
return {"status": "updated", "task": updated, "left_mytasks": False}
|
||||
|
||||
Reference in New Issue
Block a user