v5.2.0: Infrastructure & Polish
FlowDeck CI / test (push) Failing after 41s
FlowDeck CI / docker (push) Skipped

- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table)
- Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens
- Active sessions management: list/revoke via /api/settings/sessions with device info
- Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project)
- Automatic daily backups: backup_db(), prune, scheduler + admin API
- Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects()
- GitHubAdapter implements ForgeAdapter contract, transport injection for mocking
- Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs
- Linting config: ruff (Python) + eslint (JS)
- Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky)
- Bumped version to 5.9.1

Co-authored-by: Bruno <[email protected]>
This commit is contained in:
2026-09-10 23:47:35 -04:00
co-authored by Bruno
parent e9b6244ef0
commit 5c350ff8f6
77 changed files with 2447 additions and 293 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
"""FlowDeck — Admin API: users, roles, stats, audit."""
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
router = APIRouter(tags=["admin"], prefix="/api/admin")
@@ -48,9 +48,9 @@ async def list_users(_admin=Depends(admin_required)):
@router.post("/users")
async def create_user(request: Request, _admin=Depends(admin_required)):
"""Create a new user (admin only)."""
from app.db import get_conn
from app.password_utils import hash_password
import json
try:
body = await request.json()
except Exception:
@@ -80,9 +80,9 @@ async def create_user(request: Request, _admin=Depends(admin_required)):
@router.put("/users/{user_id:int}")
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
"""Update a user: name, email, password, admin status, active status."""
from app.db import get_conn
from app.password_utils import hash_password
import json
try:
body = await request.json()
except Exception:
+16 -11
View File
@@ -8,22 +8,27 @@ import asyncio
import json
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from app.db import get_conn
from app.config import settings
from app.auth.session import get_current_user
from app.config import settings
from app.db import get_conn
from app.services.agent_engine import AgentEngine, undo_action
from app.services.llm_client import LLMClient, PROVIDERS, PROVIDER_MODELS
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
from app.services.llm_config import (
get_llm_config, set_llm_config, provider_info,
get_user_llm_key, list_user_llm_keys, upsert_user_llm_key,
delete_user_llm_key, fetch_provider_models,
mark_llm_config_verified, mark_user_llm_key_verified,
delete_user_llm_key,
fetch_provider_models,
get_llm_config,
get_user_llm_key,
list_user_llm_keys,
mark_llm_config_verified,
mark_user_llm_key_verified,
provider_info,
set_llm_config,
upsert_user_llm_key,
)
from app.services.tool_registry import ToolRegistry
from app.services.permission_manager import PermissionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["agent"], prefix="/api/agent")
@@ -378,7 +383,7 @@ async def agent_writing(request: Request):
Returns plain Markdown (`text`) plus `model`/`offline` metadata. The
`properties` action is served by `/writing/properties`.
"""
from app.services.ai_writing import AIWritingService, WRITING_ACTIONS
from app.services.ai_writing import WRITING_ACTIONS, AIWritingService
user_id = await _current_user_id(request)
body = await request.json() if request.headers.get("content-type") else {}
@@ -1020,4 +1025,4 @@ async def delete_agent(request: Request, agent_id: int):
raise HTTPException(status_code=404, detail="Agent introuvable")
conn.execute("DELETE FROM agents WHERE id=?", (agent_id,))
conn.commit()
return {"id": agent_id, "status": "deleted"}
return {"id": agent_id, "status": "deleted"}
+2 -3
View File
@@ -4,16 +4,15 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.routers.board import _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS
from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card
from app.services.gitea_client import gitea
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"], prefix="/api")
+11 -9
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
import secrets
from fastapi import APIRouter, Request, Query
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
@@ -173,9 +173,9 @@ async def login(request: Request, provider: str = Query("gitea")):
@router.post("/register")
async def register(request: Request):
"""Register a new local account."""
from app.db import get_conn
from app.password_utils import hash_password
import json
try:
body = await request.json()
except Exception:
@@ -210,7 +210,7 @@ async def register(request: Request):
user_data = dict(user)
# Log login
_log_login(user_data["id"], request)
session = SessionManager.create_session(user_data)
session = SessionManager.create_session(user_data, request)
from fastapi.responses import JSONResponse
response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
@@ -220,10 +220,12 @@ async def register(request: Request):
@router.post("/local-login")
async def local_login(request: Request):
"""Login with email + password."""
from app.db import get_conn
from app.password_utils import verify_password, is_locked
import time
from fastapi.responses import JSONResponse
import json, time
from app.db import get_conn
from app.password_utils import is_locked, verify_password
try:
body = await request.json()
except Exception:
@@ -265,7 +267,7 @@ async def local_login(request: Request):
(str(time.time()), ud["id"]),
)
conn.commit()
session = SessionManager.create_session(ud)
session = SessionManager.create_session(ud, request)
_log_login(ud["id"], request)
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
@@ -363,7 +365,7 @@ async def callback(
user_data = dict(user) if user else oauth_user
# Create session
session = SessionManager.create_session(user_data)
session = SessionManager.create_session(user_data, request)
_log_login(user_data["id"], request)
response = RedirectResponse(url="/workspaces", status_code=302)
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
+4 -4
View File
@@ -4,11 +4,11 @@ from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from app.db import get_conn
from app.auth.session import SessionManager
from app.services.automations import run_automation, get_page_context
from app.db import get_conn
from app.services.automations import get_page_context, run_automation
logger = logging.getLogger(__name__)
router = APIRouter(tags=["automations"])
@@ -171,4 +171,4 @@ async def automation_runs_history(request: Request, auto_id: int, limit: int = 5
ORDER BY created_at DESC, id DESC LIMIT ?""",
(auto_id, limit),
).fetchall()
return {"runs": [dict(r) for r in rows]}
return {"runs": [dict(r) for r in rows]}
+38 -13
View File
@@ -4,15 +4,15 @@ from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.db import get_conn
from app.services.gitea_client import gitea
from app.auth.session import SessionManager
from app.db import get_conn
from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event
from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@@ -246,6 +246,26 @@ def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list
def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
"""Shared / received / published pages for the sidebar (reused by dashboard)."""
with get_conn() as conn:
own_ws = (
"SELECT w.id FROM workspaces w WHERE w.owner_id = ? "
"UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?"
)
own_ws_names = (
"SELECT w.name FROM workspaces w WHERE w.owner_id = ? "
"UNION SELECT w.name FROM workspaces w "
"JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?"
)
# Scope "shared by me"-style lists to pages in the user's own workspaces
# (or legacy pages whose workspace_id is NULL but identify the workspace by text).
scope_cond = (
f"(workspace_id IN ({own_ws}) "
f"OR (workspace_id IS NULL AND lower(workspace) IN "
f"(SELECT lower(name) FROM ({own_ws_names}))) "
f"OR (workspace_id IS NULL AND lower(workspace) = lower("
f"(SELECT login FROM users WHERE id=?))))"
)
scope_params = (user_id, user_id, user_id, user_id, user_id)
made_nominal = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
@@ -253,16 +273,22 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
(user_id,),
).fetchall()
made_link = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL"
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL "
f"AND {scope_cond}",
scope_params,
).fetchall()
made_flag = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL"
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL "
f"AND {scope_cond}",
scope_params,
).fetchall()
published_rows = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE published=1 AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20"
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
f"ORDER BY updated_at DESC LIMIT 20",
scope_params,
).fetchall()
received_rows = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
@@ -639,7 +665,6 @@ async def remove_favorite(request: Request, page_id: int):
@router.post("/api/share/{page_id:int}")
async def update_share(request: Request, page_id: int):
"""Save share settings for a page."""
import json
body = await request.json()
mode = body.get("mode", "private")
published = body.get("published", False)
@@ -956,12 +981,12 @@ async def move_page(request: Request, page_id: int):
new_ws_id = body.get("workspace_id")
new_parent_id = body.get("parent_id", 0)
new_order = body.get("new_order", 0)
with get_conn() as conn:
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
if new_ws_id:
# Move to a different workspace: get the workspace name
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
@@ -981,7 +1006,7 @@ async def move_page(request: Request, page_id: int):
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_order, page_id),
)
conn.commit()
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
"parent_id": new_parent_id})
+3 -3
View File
@@ -8,10 +8,10 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from app.db import get_conn
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import notifications as notif
logger = logging.getLogger(__name__)
@@ -181,4 +181,4 @@ async def delete_comment(request: Request, comment_id: int):
raise HTTPException(403, "Not allowed to delete this comment")
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
conn.commit()
return {"id": comment_id, "status": "deleted"}
return {"id": comment_id, "status": "deleted"}
+7 -6
View File
@@ -5,14 +5,13 @@ import json
import logging
import sqlite3
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.auth.session import SessionManager
from app.services.property_types import validate_property_rule
from app.services.db_templates import materialize_properties
from app.services.automations import fire_event
from app.services.db_templates import materialize_properties
from app.services.property_types import validate_property_rule
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
@@ -1224,7 +1223,8 @@ async def remove_page_dependency(request: Request, collection_id: int, page_id:
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
"""API: auto-shift dates based on blocking dependencies."""
from datetime import date as dt_date, timedelta
from datetime import date as dt_date
from datetime import timedelta
try:
body = await request.json()
@@ -1388,7 +1388,8 @@ h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
from datetime import date as dt_date, timedelta
from datetime import date as dt_date
from datetime import timedelta
today = dt_date.today()
# Determine month/year from config or current
year = config.get("year", today.year)
+31 -40
View File
@@ -3,12 +3,12 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.services.gitea_client import gitea, get_user_gitea_client
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.gitea_client import get_user_gitea_client, gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
@@ -253,6 +253,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided."""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = board_sidebar(request, owner, repo)
@@ -283,6 +284,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
"""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = board_sidebar(request, owner, repo)
@@ -311,6 +313,7 @@ async def view_page_root(request: Request, page_id: int):
?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1"
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
with get_conn() as conn:
@@ -428,7 +431,7 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request):
"""Comprehensive help & documentation page."""
from jinja2 import Environment, FileSystemLoader, BaseLoader
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
# Render via a block-based template so content_html lands in {% block content %}
@@ -612,7 +615,6 @@ def _get_user_id(request: Request) -> int:
@router.put("/api/user/profile")
async def update_profile(request: Request):
import json
body = await request.json()
full_name = body.get("full_name", "").strip()
uid = _get_user_id(request)
@@ -624,7 +626,6 @@ async def update_profile(request: Request):
@router.put("/api/user/password")
async def update_password(request: Request):
import json
from app.password_utils import hash_password
body = await request.json()
password = body.get("password", "").strip()
@@ -710,7 +711,8 @@ async def dashboard(
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
).fetchone()[0]
if ws_count == 0:
return RedirectResponse("/workspaces", status_code=302)
# v5.2.0: first-launch → onboarding wizard
return RedirectResponse("/welcome", status_code=302)
except Exception:
pass
return RedirectResponse("/local-workspace", status_code=302)
@@ -760,6 +762,7 @@ async def workspace_page(request: Request):
async def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files."""
import json
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
@@ -816,7 +819,7 @@ async def list_workspace_projects(request: Request):
"""List all projects: built-in + Gitea + GitHub.
Uses the user's own Gitea token if connected, not the global admin token."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
builtin = []
with get_conn() as conn:
rows = conn.execute(
@@ -850,7 +853,6 @@ async def list_workspace_projects(request: Request):
@router.post("/api/workspace/projects")
async def create_workspace_project(request: Request):
import json
body = await request.json()
name = body.get("name", "").strip()
if not name:
@@ -882,7 +884,6 @@ async def list_members(request: Request, ws_id: int):
@router.post("/api/workspace/{ws_id:int}/members")
async def invite_member(request: Request, ws_id: int):
"""Invite a user to a workspace by email."""
import json
body = await request.json()
email = body.get("email", "").strip()
role = body.get("role", "editor")
@@ -906,7 +907,6 @@ async def invite_member(request: Request, ws_id: int):
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
"""Change a member's role."""
import json
body = await request.json()
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
@@ -1048,14 +1048,14 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
# Get workspace owner name for author display
ws_owner = conn.execute(
"SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?",
(ws_id,),
).fetchone()
author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—"
# Collect all page IDs to fetch tags in one query
all_ids = [r["id"] for r in rows]
tags_map = {}
@@ -1070,12 +1070,12 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
tags_map.setdefault(tr["page_id"], []).append({
"id": tr["id"], "name": tr["name"], "color": tr["color"],
})
tree = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
children = _build_tree_children(conn, r["id"], ws_id)
# Compute size
size = 0
if r["content_format"] == "file":
@@ -1087,7 +1087,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
size = len(r["content"] or "")
else:
size = len(r["content"] or "")
tree.append({
"id": r["id"],
"name": r["title"] or "Untitled",
@@ -1238,7 +1238,6 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
@router.post("/api/local-workspace/items")
async def create_local_workspace_item(request: Request):
"""Create a new file in the active workspace."""
import json
body = await request.json()
name = body.get("name", "Untitled").strip()
item_type = body.get("type", "page")
@@ -1262,7 +1261,6 @@ async def create_local_workspace_item(request: Request):
@router.put("/api/local-workspace/items/{item_id:int}")
async def rename_local_workspace_item(request: Request, item_id: int):
"""Rename a file."""
import json
body = await request.json()
name = body.get("name", "Untitled").strip()
with get_conn() as conn:
@@ -1299,8 +1297,8 @@ async def restore_local_workspace_item(request: Request, item_id: int):
@router.get("/api/files/{ws_id:int}/{filename:path}")
async def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
from pathlib import Path
import mimetypes
from pathlib import Path
base_dir = Path(f"/data/uploads/workspace_{ws_id}").resolve()
fp = (base_dir / filename).resolve()
try:
@@ -1318,7 +1316,6 @@ async def serve_uploaded_file(ws_id: int, filename: str):
@router.put("/api/local-workspace/items/{item_id:int}/move")
async def move_local_workspace_item(request: Request, item_id: int):
"""Move an item to a new parent (drag & drop)."""
import json
body = await request.json()
new_parent_id = body.get("parent_id") # None = move to root
with get_conn() as conn:
@@ -1583,7 +1580,6 @@ async def list_workspaces(request: Request):
@router.post("/api/workspaces")
async def create_workspace(request: Request):
"""Create a new workspace."""
import json
body = await request.json()
name = body.get("name", "New Workspace").strip()
if not name:
@@ -1616,7 +1612,6 @@ async def create_workspace(request: Request):
@router.put("/api/workspaces/{ws_id:int}")
async def rename_workspace(request: Request, ws_id: int):
"""Rename a workspace."""
import json
body = await request.json()
name = body.get("name", "").strip()
if not name:
@@ -1669,9 +1664,9 @@ async def app_settings_page(request: Request):
@router.post("/api/settings/avatar")
async def upload_avatar(request: Request):
"""Upload a user avatar image."""
from fastapi import UploadFile, File
import os
import uuid
from pathlib import Path
import uuid, os
form = await request.form()
file = form.get("file")
if not file:
@@ -1698,8 +1693,9 @@ async def upload_avatar(request: Request):
@router.get("/api/settings/avatar/{filename:path}")
async def serve_avatar_file(filename: str):
"""Serve an uploaded avatar image file."""
from fastapi.responses import FileResponse
from pathlib import Path
from fastapi.responses import FileResponse
filepath = Path("/data/avatars") / filename
if not filepath.is_file():
return JSONResponse({"error": "Not found"}, status_code=404)
@@ -1735,7 +1731,6 @@ async def set_avatar_color(request: Request):
@router.post("/api/settings/tags")
async def create_tag_global(request: Request):
"""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")
@@ -1756,7 +1751,6 @@ async def create_tag_global(request: Request):
@router.put("/api/settings/tags/{tag_id:int}")
async def update_tag_global(tag_id: int, request: Request):
"""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:
@@ -1831,7 +1825,6 @@ 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, scoped to user)."""
import json
body = await request.json()
tag_name = body.get("name", "").strip().lower()
tag_color = body.get("color", "#787774")
@@ -1884,7 +1877,6 @@ async def search_by_tags(request: Request, tags: str = ""):
ws_id = ws["id"] if ws else None
if not ws_id:
return {"items": []}
import json
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
if not tag_names:
return {"items": []}
@@ -1921,7 +1913,6 @@ async def search_by_tags(request: Request, tags: str = ""):
@router.put("/api/settings/account")
async def update_account(request: Request):
"""Update current user's profile: full_name, login, email, password."""
import json
from app.password_utils import hash_password
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -1951,8 +1942,9 @@ async def update_account(request: Request):
conn.commit()
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
user_data = dict(row)
# Refresh session cookie with updated data
new_session = SessionManager.create_session(user_data)
# Refresh session cookie with updated data (keeps the same session id)
cookie = request.cookies.get("flowdeck_session", "")
new_session = SessionManager.refresh_session(cookie, user_data, request)
response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}})
response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
return response
@@ -1968,20 +1960,21 @@ async def sidebar_workspace_tree(request: Request):
in the main content area to keep the sidebar in sync.
"""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _load_workspace_pages
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return HTMLResponse("")
ws_cookie = request.cookies.get("flowdeck_workspace", "")
if not ws_cookie:
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
# Gitea workspace — no server-side tree, loaded client-side
if ws_cookie.startswith("gitea:"):
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
try:
ws_id = int(ws_cookie)
with get_conn() as conn:
@@ -1995,14 +1988,14 @@ async def sidebar_workspace_tree(request: Request):
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
pages = _load_workspace_pages(ws_cookie)
if not pages:
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
# Render the tree using the extracted macro
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.from_string(
@@ -2079,7 +2072,6 @@ def _sanitize_id(block_id: str) -> str:
def _render_blocks_public(blocks: list) -> str:
"""Render FlowDeck blocks as plain HTML for public pages."""
import json as _json
html_parts = []
for b in blocks:
t = b.get("type", "paragraph")
@@ -2207,7 +2199,6 @@ async def api_page_content(page_id: int):
@router.put("/api/pages/{page_id:int}/rename")
async def api_rename_page(page_id: int, request: Request):
"""Inline rename a page title."""
import json as _json
body = await request.json()
title = (body.get("title") or "").strip()
if not title:
+1 -1
View File
@@ -13,10 +13,10 @@ from fastapi.responses import Response
from app.db import get_conn
from app.services.export import (
build_static_site_bytes,
page_to_markdown,
page_to_pdf_bytes,
page_to_standalone_html,
build_static_site_bytes,
)
logger = logging.getLogger(__name__)
+3 -5
View File
@@ -1,5 +1,5 @@
"""FlowDeck — Gitea integration API routes."""
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
@@ -92,7 +92,6 @@ async def get_file(request: Request, owner: str, repo: str, path: str):
@router.put("/projects/{owner}/{repo}/file")
async def save_file(request: Request, owner: str, repo: str):
"""Create or update a file in the repo."""
import json
gitea = _require_gitea(request) # admin token can write too
try:
body = await request.json()
@@ -140,7 +139,6 @@ async def upload_file(request: Request, owner: str, repo: str):
@router.delete("/projects/{owner}/{repo}/file")
async def delete_file(request: Request, owner: str, repo: str):
"""Delete a file from the repo."""
import json
gitea = _require_gitea(request) # admin token can write too
path = request.query_params.get("path", "")
sha = request.query_params.get("sha", "")
@@ -214,9 +212,9 @@ async def list_private_pages(owner: str, repo: str, request: Request):
@router.post("/projects/{owner}/{repo}/private-pages")
async def create_private_page(owner: str, repo: str, request: Request):
"""Create a new private page for this Gitea project."""
from app.auth.session import SessionManager
from app.db import get_conn
import json
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
@@ -255,9 +253,9 @@ async def get_private_page(owner: str, repo: str, page_id: int, request: Request
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Update a private page."""
from app.auth.session import SessionManager
from app.db import get_conn
import json
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
+6 -7
View File
@@ -3,11 +3,10 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Query, Request
from app.db import get_conn
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["library"], prefix="/api/library")
@@ -174,10 +173,10 @@ async def library_favorites(
uid = _get_user_id(request)
query = (
f"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
f"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
f"FROM favorites f JOIN pages p ON p.id = f.page_id "
f"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
"FROM favorites f JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
params: list = [uid]
if tree:
+2 -2
View File
@@ -4,12 +4,12 @@ from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from jinja2 import Environment, FileSystemLoader
from app.db import get_conn
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
+2
View File
@@ -22,6 +22,7 @@ async def get_notes(request: Request, owner: str, repo: str):
content = row["content"] if row else ""
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -45,6 +46,7 @@ async def save_notes(request: Request, owner: str, repo: str):
conn.commit()
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+3 -3
View File
@@ -3,10 +3,10 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from app.db import get_conn
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["notifications"], prefix="/api/notifications")
@@ -123,4 +123,4 @@ async def search_users(request: Request, q: str = ""):
"""SELECT id, login, full_name, avatar_url, avatar_color
FROM users ORDER BY login LIMIT 20"""
).fetchall()
return {"users": [dict(r) for r in rows]}
return {"users": [dict(r) for r in rows]}
+135
View File
@@ -0,0 +1,135 @@
"""FlowDeck — v5.2.0 Onboarding: /welcome wizard + its API.
First-launch experience: create workspace → connect a forge (optional) →
create the first project (welcome page), then land in the app.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["onboarding"])
WORKSPACE_COOKIE = "flowdeck_workspace"
def _require_user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Not authenticated")
return user
@router.get("/welcome", response_class=HTMLResponse)
async def onboarding_page(request: Request):
"""Onboarding wizard. Redirects logged-out users to login and users who
already have a workspace straight to the app."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
with get_conn() as conn:
ws_count = conn.execute(
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user["id"],)
).fetchone()[0]
if ws_count > 0:
return RedirectResponse("/workspaces", status_code=302)
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.get_template("welcome.html")
return HTMLResponse(content=template.render(
user=user,
gitea_url_configured=_forge_configured("gitea"),
github_url_configured=_forge_configured("github"),
))
def _forge_configured(provider: str) -> bool:
try:
from app.auth.providers import get_provider
return get_provider(provider) is not None
except Exception:
return False
# ═══════════ Onboarding API ═══════════
@router.post("/api/onboarding/workspace")
async def onboarding_create_workspace(request: Request):
"""Step 1 — create the first local workspace."""
user = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip() or "My Workspace"
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, '{}')",
(name, user["id"]),
)
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
(cur.lastrowid, user["id"]),
)
conn.commit()
ws_id = cur.lastrowid
response = JSONResponse({"status": "ok", "id": ws_id, "name": name})
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
return response
@router.post("/api/onboarding/project")
async def onboarding_create_project(request: Request):
"""Step 3 — create the first project: a welcome page in the workspace."""
user = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
title = (body.get("title") or "").strip() or "Welcome to FlowDeck"
workspace_id = body.get("workspace_id")
with get_conn() as conn:
ws = None
if workspace_id:
ws = conn.execute(
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(workspace_id, user["id"]),
).fetchone()
if not ws:
ws = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
(user["id"],),
).fetchone()
if not ws:
raise HTTPException(status_code=400, detail="Create a workspace first")
blocks = [
{"id": "1", "type": "heading_1", "content": title},
{"id": "2", "type": "paragraph",
"content": "Welcome to FlowDeck 🎉 — your workspace is ready."},
{"id": "3", "type": "paragraph",
"content": "Use the slash command « / » in any page to add blocks, databases, to-dos and more."},
{"id": "4", "type": "paragraph",
"content": "Connect Gitea or GitHub in Settings → Integrations to sync your repositories."},
]
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) "
"VALUES (?, ?, ?, ?, 'blocks', 'Private')",
(user.get("login", "local"), ws["id"], title, json.dumps(blocks)),
)
conn.commit()
page_id = cur.lastrowid
return {"status": "ok", "id": page_id, "title": title, "workspace_id": ws["id"]}
+67
View File
@@ -0,0 +1,67 @@
"""FlowDeck — v5.2.0 Projects API: list, register, manual sync + backups admin."""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.services import projects as projects_svc
from app.services.backup import backup_db, list_backups
logger = logging.getLogger(__name__)
router = APIRouter(tags=["projects"], prefix="/api/projects")
backups_router = APIRouter(tags=["backups"])
def _require_admin(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin only")
return user
@router.get("")
async def list_projects(request: Request):
"""List all synced projects (optionally filtered by type)."""
proj_type = request.query_params.get("type") or None
return {"projects": projects_svc.list_projects(proj_type)}
@router.post("")
async def create_project(request: Request):
"""Register a standalone (builtin) project."""
body = await request.json()
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name required")
project = projects_svc.create_builtin_project(name, body.get("owner", ""), body.get("description", ""))
return {"status": "ok", "project": project}
@router.post("/sync")
async def sync_projects(request: Request):
"""Trigger an immediate forge sync for every connected account."""
_require_admin(request)
stats = await projects_svc.sync_all_projects()
return {"status": "ok", "stats": stats}
# ═══════════ Backups (admin) ═══════════
@backups_router.post("/api/settings/backups/run")
async def run_backup_now(request: Request):
"""Admin: create a database backup immediately."""
_require_admin(request)
filename = backup_db()
if not filename:
raise HTTPException(status_code=400, detail="Backups disabled or no database file")
return {"status": "ok", "filename": filename}
@backups_router.get("/api/settings/backups")
async def admin_list_backups(request: Request):
"""Admin: list stored backups."""
_require_admin(request)
return {"backups": list_backups()}
+46 -10
View File
@@ -1,12 +1,13 @@
"""FlowDeck — Public API router (v2.1.0)."""
"""FlowDeck — Public API router (v2.1.0, v5.2.0 per-user tokens)."""
from __future__ import annotations
import json
import hashlib
import logging
from secrets import token_urlsafe
from fastapi import APIRouter, Request, HTTPException, Header, Depends
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
@@ -14,28 +15,63 @@ router = APIRouter(tags=["public-api"], prefix="/api/v1")
DEFAULT_TOKEN = "fd-public-key"
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _token_owner(token: str) -> dict | None:
"""Resolve an api_tokens row by its sha256 hash (revoked → None)."""
with get_conn() as conn:
row = conn.execute(
"SELECT id, user_id, name FROM api_tokens WHERE token_hash=? AND revoked=0",
(_hash_token(token),),
).fetchone()
if not row:
return None
conn.execute(
"UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],)
)
conn.commit()
return dict(row)
def verify_token(authorization: str | None = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "API token required. Generate one via POST /api/v1/token.")
raise HTTPException(401, "API token required. Generate one via Settings → API tokens.")
token = authorization[7:] # strip "Bearer "
if token == DEFAULT_TOKEN:
return token
with get_conn() as conn:
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
if not row:
if row:
return token
owner = _token_owner(token)
if not owner:
raise HTTPException(403, "Invalid API token")
return token
@router.post("/token")
async def generate_token(request: Request):
"""Generate a public API access token."""
"""Generate a public API access token.
When an authenticated session is present the token is bound to that user
(revocable from Settings → API tokens); otherwise a legacy shared token is
created for backward compatibility.
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(0, token),
)
if user and user.get("id"):
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
(user["id"], "API token", _hash_token(token), token[:12]),
)
else:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(0, token),
)
conn.commit()
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
+1 -1
View File
@@ -46,4 +46,4 @@ async def ws_page(websocket: WebSocket, page_id: int):
except Exception as e: # noqa: BLE001
logger.debug("ws closed: %s", e)
finally:
await manager.disconnect(conn)
await manager.disconnect(conn)
+2 -2
View File
@@ -5,7 +5,7 @@ editor pages and databases scoped to the current user's accessible workspaces.
"""
from __future__ import annotations
from fastapi import APIRouter, Request, Query
from fastapi import APIRouter, Query, Request
from app.auth.session import SessionManager
from app.services.search import search as search_service
@@ -24,4 +24,4 @@ async def search(request: Request, q: str = Query(default="")):
"pages": data["pages"],
"collections": data["collections"],
"total": len(data["pages"]) + len(data["collections"]),
}
}
+121
View File
@@ -0,0 +1,121 @@
"""FlowDeck — v5.2.0 Security: per-user API tokens & active sessions.
Backend for the Settings → API tokens / Sessions UI.
"""
from __future__ import annotations
import hashlib
import logging
from secrets import token_urlsafe
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["security"], prefix="/api/settings")
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _current_user_id(request: Request) -> int:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Not authenticated")
return user["id"]
# ═══════════ API tokens ═══════════
@router.get("/tokens")
async def list_tokens(request: Request):
"""List the current user's API tokens (prefix only, no secrets)."""
uid = _current_user_id(request)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, token_prefix, last_used_at, revoked, created_at "
"FROM api_tokens WHERE user_id=? ORDER BY created_at DESC",
(uid,),
).fetchall()
return {"tokens": [dict(r) for r in rows]}
@router.post("/tokens")
async def create_token(request: Request):
"""Create an API token for the current user. The secret is returned once."""
uid = _current_user_id(request)
body = await request.json()
name = (body.get("name") or "").strip() or "API token"
token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
(uid, name[:80], _hash_token(token), token[:12]),
)
conn.commit()
tid = cur.lastrowid
return {"id": tid, "name": name, "token": token,
"note": "Copy this token now — it won't be shown again."}
@router.delete("/tokens/{token_id:int}")
async def revoke_token(token_id: int, request: Request):
"""Revoke an API token (soft delete)."""
uid = _current_user_id(request)
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM api_tokens WHERE id=? AND user_id=?", (token_id, uid)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Token not found")
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
conn.commit()
return {"status": "revoked"}
# ═══════════ Active sessions ═══════════
@router.get("/sessions")
async def list_sessions(request: Request):
"""List the current user's active sessions with their devices."""
uid = _current_user_id(request)
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
sessions = SessionManager.list_sessions(uid)
now = __import__("datetime").datetime.now()
for s in sessions:
s["is_current"] = (s["id"] == current_sid)
# A session older than 7 days is implicitly expired (cookie max-age).
created = s.get("created_at") or ""
try:
from datetime import datetime
created_dt = datetime.fromisoformat(str(created).replace("Z", ""))
s["expired"] = (now - created_dt).days >= 7
except Exception:
s["expired"] = False
return {"sessions": sessions}
@router.post("/sessions/{sid}/revoke")
async def revoke_session(sid: str, request: Request):
"""Revoke an active session. If it's the current one, the user is logged out."""
uid = _current_user_id(request)
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM user_sessions WHERE id=? AND user_id=?", (sid, uid)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Session not found")
SessionManager.revoke_session(sid)
# Also wipe the OAuth state cookie if the current session was revoked.
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
if current_sid == sid:
try:
request.session.clear()
except Exception:
pass
return {"status": "revoked"}
+2 -2
View File
@@ -6,10 +6,10 @@ import re
import unicodedata
from datetime import datetime
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from app.db import get_conn
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sharing"], prefix="/api")
+1 -1
View File
@@ -6,8 +6,8 @@ import logging
from fastapi import APIRouter, HTTPException, Request
from app.db import get_conn
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sidebar"], prefix="/api/sidebar")
+3 -3
View File
@@ -1,12 +1,12 @@
"""FlowDeck — Webhook receiver for real-time Gitea sync."""
from __future__ import annotations
import json
import hmac
import hashlib
import hmac
import json
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from app.config import settings
from app.db import get_conn
+3 -3
View File
@@ -7,11 +7,11 @@ import json
import logging
import sqlite3
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from app.db import get_conn
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event
logger = logging.getLogger(__name__)
@@ -561,7 +561,7 @@ async def sprint_burndown(request: Request, collection_id: int, sid: int):
completed += p["velocity_points"]
break
from datetime import date, timedelta
from datetime import date
today = date.today()
start = date.fromisoformat(sprint["start_date"]) if sprint["start_date"] else today
end = date.fromisoformat(sprint["end_date"]) if sprint["end_date"] else today