feat(v5.2.0): finalize Infrastructure & Polish (tests isolation, xdist, lint, CI)
FlowDeck CI / lint (push) Successful in 43s
FlowDeck CI / test (push) Successful in 4m2s
FlowDeck CI / lint (pull_request) Successful in 42s
FlowDeck CI / test (pull_request) Successful in 4m3s
FlowDeck CI / docker (push) Successful in 1m2s
FlowDeck CI / docker (pull_request) Successful in 35s

tests/conftest.py: mutate the settings singleton (instead of rebinding) so DB + backup dir are isolated per test -> pytest-xdist safe.
Real backup tests (snapshot/prune/admin API) and OAuth mock tests (Gitea/GitHub/link) replace the previous skips.
init_db() now also creates webhook_subscriptions (full schema without the FastAPI lifespan).
ruff check is clean; .eslintrc.json migrated to eslint.config.mjs (flat config).
CI: lint job (ruff + eslint), parallel tests (-n auto), run on every branch push.
VERSION 5.11.1.
This commit is contained in:
2026-09-11 23:36:53 -04:00
parent 881c3e3e0a
commit ba363eaee9
42 changed files with 566 additions and 206 deletions
+2
View File
@@ -1,3 +1,5 @@
"""FlowDeck — Auth module: session, OAuth2, dependencies."""
from app.auth.oauth import GiteaOAuth
from app.auth.session import SessionManager, get_current_user
__all__ = ["GiteaOAuth", "SessionManager", "get_current_user"]
+5
View File
@@ -817,6 +817,11 @@ def init_db():
from app.migrations import apply_migrations
apply_migrations(conn)
# Webhook subscriptions (v2.1.0) — created here (idempotent) so the full
# schema exists without depending on the FastAPI lifespan startup.
from app.services.webhook_outbound import init_webhook_tables
init_webhook_tables()
@contextmanager
def get_conn():
+2 -2
View File
@@ -85,7 +85,7 @@ async def lifespan(_app: FastAPI):
from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler())
logger.info("FlowDeck v5.10.0 started on port %d", settings.app_port)
logger.info("FlowDeck v5.11.1 started on port %d", settings.app_port)
try:
yield
finally:
@@ -100,7 +100,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="5.10.0",
version="5.11.1",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+2
View File
@@ -1,2 +1,4 @@
"""FlowDeck — Custom middleware."""
from app.middleware.csrf import CSRFMiddleware
__all__ = ["CSRFMiddleware"]
+4 -4
View File
@@ -178,7 +178,7 @@ async def create_agent(request: Request):
)
conn.commit()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail=f"Impossible de créer l'agent: {exc}")
raise HTTPException(status_code=409, detail=f"Impossible de créer l'agent: {exc}") from exc
return {"id": cur.lastrowid, "name": name, "status": "created"}
@@ -466,9 +466,9 @@ async def undo(request: Request, action_id: int):
try:
undo_action(action_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=f"Rollback échoué: {exc}")
raise HTTPException(status_code=500, detail=f"Rollback échoué: {exc}") from exc
return {"id": action_id, "status": "reverted"}
@@ -501,7 +501,7 @@ async def create_skill(request: Request):
)
conn.commit()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}")
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") from exc
return {"id": cur.lastrowid, "name": name, "status": "created"}
+4 -4
View File
@@ -149,7 +149,7 @@ async def move_card(
issue = await gitea.get_issue(owner, repo, issue_id)
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
status_labels = await _get_status_labels(owner, repo, board_id)
filtered_names = [l for l in current_labels if l not in status_labels]
filtered_names = [name for name in current_labels if name not in status_labels]
filtered_names.append(mapping["gitea_label"])
# Resolve label names to IDs
@@ -293,7 +293,7 @@ async def create_issue(
if not _check_rate_limit(request):
raise HTTPException(status_code=429, detail="Rate limit exceeded")
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] if labels else None
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()] if labels else None
milestone_id = int(milestone) if milestone.strip().isdigit() else None
issue = await gitea.create_issue(
@@ -345,7 +345,7 @@ async def update_issue_api(
if state:
kwargs["state"] = state
if labels:
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()]
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()]
if milestone and milestone.strip().isdigit():
kwargs["milestone"] = int(milestone)
if assignee:
@@ -388,7 +388,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
comments = await gitea.get_issue_comments(owner, repo, issue_id)
except Exception as e:
logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e)
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found")
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") from e
# Get checklists from local DB
with get_conn() as conn:
+1 -1
View File
@@ -53,7 +53,7 @@ def _validate_payload(body: dict) -> None:
else:
json.dumps(val)
except (TypeError, json.JSONDecodeError):
raise HTTPException(status_code=400, detail=f"{key} must be valid JSON")
raise HTTPException(status_code=400, detail=f"{key} must be valid JSON") from None
@router.get("/workspace/automations")
+41 -27
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import logging
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
@@ -150,20 +151,34 @@ def _file_icon(name: str, content_format: str = "") -> str:
if content_format and content_format != 'file':
return 'edit'
n = name.lower()
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return 'image'
if n.endswith('.pdf'): return 'file'
if re.search(r'\.(md|markdown)$', n): return 'edit'
if n.endswith('.py'): return 'file'
if re.search(r'\.(js|jsx|ts|tsx)$', n): return 'file'
if re.search(r'\.(html?|xml)$', n): return 'file'
if n.endswith('.css'): return 'file'
if n.endswith('.json'): return 'file'
if n.endswith('.sql'): return 'file'
if re.search(r'\.(sh|bash|zsh)$', n): return 'file'
if n.endswith('.ps1'): return 'file'
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return 'file'
if re.search(r'\.(txt|log)$', n): return 'file'
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return 'file'
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
return 'image'
if n.endswith('.pdf'):
return 'file'
if re.search(r'\.(md|markdown)$', n):
return 'edit'
if n.endswith('.py'):
return 'file'
if re.search(r'\.(js|jsx|ts|tsx)$', n):
return 'file'
if re.search(r'\.(html?|xml)$', n):
return 'file'
if n.endswith('.css'):
return 'file'
if n.endswith('.json'):
return 'file'
if n.endswith('.sql'):
return 'file'
if re.search(r'\.(sh|bash|zsh)$', n):
return 'file'
if n.endswith('.ps1'):
return 'file'
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n):
return 'file'
if re.search(r'\.(txt|log)$', n):
return 'file'
if re.search(r'\.(zip|tar|gz|rar|7z)$', n):
return 'file'
return 'file'
@@ -847,7 +862,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
)
conn.commit()
except Exception as e:
raise HTTPException(409, f"Property already exists: {e}")
raise HTTPException(409, f"Property already exists: {e}") from e
return {"status": "ok", "name": name, "type": prop_type}
@@ -901,7 +916,7 @@ async def extract_ai_keywords(owner: str, repo: str):
if not issue.get("pull_request"):
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
except Exception as e:
raise HTTPException(500, str(e))
raise HTTPException(500, str(e)) from e
return {"status": "ok", "issues_scanned": len(issues)}
@@ -980,7 +995,7 @@ async def save_page_blocks(request: Request, page_id: int):
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body")
raise HTTPException(400, "Invalid JSON body") from None
blocks_json = json.dumps(body.get("blocks", []))
title = body.get("title", "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -1119,7 +1134,7 @@ async def restore_version(request: Request, page_id: int, version_id: int):
@router.post("/api/pages/{page_id}/duplicate")
async def duplicate_page(request: Request, page_id: int):
"""Duplicate a page (block/markdown content included) as a sibling."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)
@@ -1130,7 +1145,7 @@ async def duplicate_page(request: Request, page_id: int):
def copy_tree(src_id: int, parent_id) -> int:
with get_conn() as conn:
src = conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
"parent_section, parent_id, sort_order, share_mode, published, is_published, "
@@ -1161,9 +1176,8 @@ async def duplicate_page(request: Request, page_id: int):
# ═══════════ v5.5.0: Cover & icon ═══════════
def _upload_root() -> "Path":
def _upload_root() -> Path:
import os
from pathlib import Path
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
@@ -1296,7 +1310,7 @@ async def import_page(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body")
raise HTTPException(400, "Invalid JSON body") from None
markdown = body.get("markdown", "")
title = body.get("title", "")
if not markdown and not body.get("csv"):
@@ -1328,7 +1342,7 @@ async def import_file(request: Request):
try:
zf = zipfile.ZipFile(_io.BytesIO(data))
except zipfile.BadZipFile:
raise HTTPException(400, "Invalid zip archive")
raise HTTPException(400, "Invalid zip archive") from None
md_entries = sorted(
(n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))),
key=lambda n: (n.count("/"), n.lower()),
@@ -1346,7 +1360,7 @@ async def import_file(request: Request):
try:
raw = data.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(400, "Only text/markdown files are supported")
raise HTTPException(400, "Only text/markdown files are supported") from None
title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "")
created_ids.append(await _create_page_from_markdown(request, raw, title))
@@ -1361,7 +1375,7 @@ async def og_metadata(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body")
raise HTTPException(400, "Invalid JSON body") from None
url = (body.get("url") or "").strip()
if not url:
raise HTTPException(400, "url required")
@@ -1373,7 +1387,7 @@ async def og_metadata(request: Request):
@router.put("/api/pages/{page_id}/move")
async def move_page(request: Request, page_id: int):
"""Move a page to another workspace or reorder within tree.
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
- workspace_id: move page to a different workspace
- parent_id: change parent (0 = root level)
@@ -1526,4 +1540,4 @@ async def sync_project(owner: str, repo: str):
conn.commit()
return {"status": "ok", "issues_synced": len(issues_only)}
except Exception as e:
raise HTTPException(500, str(e))
raise HTTPException(500, str(e)) from e
+1 -1
View File
@@ -50,7 +50,7 @@ def _serialize(rows):
@router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int):
"""List page-level and inline comments for a FlowDeck page."""
user = _current_user(request)
_current_user(request)
with get_conn() as conn:
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
+9 -10
View File
@@ -304,7 +304,6 @@ async def duplicate_collection_api(request: Request, collection_id: int):
)
# ── Pages (rows) with property ids remapped to the copy's properties ──
proxies = {}
prows = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
@@ -554,7 +553,7 @@ async def create_property_api(request: Request, collection_id: int):
)
conn.commit()
except Exception:
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists")
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"}
@@ -980,7 +979,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
except Exception:
body = {}
new_status = body.get("new_status", "Done")
body.get("new_status", "Done")
with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
@@ -1070,7 +1069,7 @@ async def add_data_source(request: Request, collection_id: int):
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This data source already exists in this collection")
raise HTTPException(status_code=409, detail="This data source already exists in this collection") from None
return {
"id": cur.lastrowid,
@@ -1109,7 +1108,7 @@ async def create_linked_database(request: Request, collection_id: int):
body = {}
name = body.get("name", "").strip()
new_workspace_id = body.get("workspace_id")
body.get("workspace_id")
with get_conn() as conn:
source = conn.execute(
@@ -1341,7 +1340,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This dependency already exists")
raise HTTPException(status_code=409, detail="This dependency already exists") from None
return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
@@ -1604,7 +1603,7 @@ def _render_gallery(view_type: str, collection: dict, pages: list[dict], config:
cover_url = config.get("cover_property")
cover_html = ""
if cover_url:
for k, v in props.items():
for _k, v in props.items():
if isinstance(v, list) and len(v) > 0:
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
if url.startswith("http"):
@@ -1674,7 +1673,7 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
start_val = end_val = None
for k, v in props.items():
for _k, v in props.items():
if isinstance(v, str) and v.startswith("20"):
if "..." in v:
parts = v.split("...")
@@ -1847,7 +1846,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
lat, lng = None, None
for k, v in props.items():
for _k, v in props.items():
if isinstance(v, str) and "," in v:
parts = v.split(",")
try:
@@ -1914,7 +1913,7 @@ def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: d
props = json.loads(p.get("property_values_json", "{}"))
group = None
start_val = end_val = None
for k, v in props.items():
for _k, v in props.items():
if isinstance(v, str) and v.startswith("20"):
if "→" in v:
parts = v.split("→")
+5 -5
View File
@@ -217,7 +217,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
from app.routers.board import _load_shared_sidebar_pages
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"])
return {
sidebar = {
"workspace_name": ws, "workspace_initial": initial,
"active_ws_name": active_ws_name,
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
@@ -937,7 +937,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
@router.get("/local-workspace", response_class=HTMLResponse)
async def local_workspace_page(request: Request, folder: int = None):
"""Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
"""
from jinja2 import Environment, FileSystemLoader
@@ -984,7 +984,7 @@ async def local_workspace_page(request: Request, folder: int = None):
@router.get("/api/local-workspace/tree")
async def local_workspace_tree(request: Request, folder: int = None):
"""Return the file/folder tree filtered by active workspace.
If ?folder=ID is provided, returns only that folder's children.
Otherwise returns the full recursive tree from root.
"""
@@ -1965,7 +1965,7 @@ async def update_account(request: Request):
@router.get("/api/sidebar/workspace-tree")
async def sidebar_workspace_tree(request: Request):
"""Return the sidebar workspace tree as HTML fragment.
Called by appState().refreshSidebarTree() after CRUD operations
in the main content area to keep the sidebar in sync.
"""
@@ -2296,7 +2296,7 @@ async def api_trash_page(page_id: int):
@router.post("/api/pages/{page_id:int}/convert-to-database")
async def api_convert_to_database(page_id: int, request: Request):
"""Convert a page into a full-page database (Notion-style).
Creates a collection linked to this page, adds the default 'Name' property,
and sets the page's content_format to 'collection'.
"""
+2 -2
View File
@@ -74,10 +74,10 @@ async def export_pdf(page_id: int, request: Request):
try:
pdf_bytes = page_to_pdf_bytes(page)
except ImportError:
raise HTTPException(status_code=501, detail="PDF export requires 'weasyprint' or 'xhtml2pdf'")
raise HTTPException(status_code=501, detail="PDF export requires 'weasyprint' or 'xhtml2pdf'") from None
except Exception as exc: # noqa: BLE001
logger.error("PDF export failed for page %s: %s", page_id, exc)
raise HTTPException(status_code=500, detail="PDF generation failed")
raise HTTPException(status_code=500, detail="PDF generation failed") from exc
filename = _safe_filename(page, "pdf")
headers = _download_header(filename, "application/pdf")
return Response(content=pdf_bytes, status_code=200, headers=headers)
+5 -4
View File
@@ -7,7 +7,7 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
def _require_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401.
Only returns a client if the user has personally connected their Gitea
account (OAuth token). No fallback to admin token — each user must link
their own Gitea account to see Gitea projects.
@@ -160,8 +160,8 @@ async def get_labels(request: Request, owner: str, repo: str):
try:
labels = await gitea.get_labels(owner, repo)
return {"labels": [
{"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")}
for l in labels
{"id": lbl["id"], "name": lbl["name"], "color": lbl.get("color", "#787774")}
for lbl in labels
]}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
@@ -327,7 +327,8 @@ async def sync_labels(request: Request, owner: str, repo: str):
for label in labels:
name = label.get("name", "")
color = label.get("color", "#787774")
if not name: continue
if not name:
continue
existing = conn.execute(
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
).fetchone()
+27 -14
View File
@@ -59,9 +59,12 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
elif content_format == "file":
icon = "📄"
fn = title.lower()
if fn.endswith(".pdf"): icon = "📕"
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
else:
icon = "📝"
@@ -332,7 +335,7 @@ async def library_private(
@router.get("/local-workspace-children/{item_id:int}")
async def library_local_workspace_children(item_id: int, request: Request):
"""Return children of a local workspace item for tree expansion."""
uid = _get_user_id(request)
_get_user_id(request)
with get_conn() as conn:
# Get the item to find its workspace
item = conn.execute(
@@ -358,9 +361,12 @@ async def library_local_workspace_children(item_id: int, request: Request):
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"): icon = "📕"
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
with get_conn() as conn:
child_count = conn.execute(
@@ -468,9 +474,12 @@ async def library_local_workspace(
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"): icon = "📕"
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
# Check for children
child_count = conn.execute(
@@ -505,10 +514,14 @@ async def library_local_workspace(
def _format_size(size_bytes):
if not size_bytes: return ""
if size_bytes < 1024: return f"{size_bytes} B"
if size_bytes < 1048576: return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824: return f"{size_bytes/1048576:.1f} MB"
if not size_bytes:
return ""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1048576:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824:
return f"{size_bytes/1048576:.1f} MB"
return f"{size_bytes/1073741824:.1f} GB"
+1 -1
View File
@@ -121,7 +121,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON."""
user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin"
user.get("login", "admin") if user else "admin"
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
+1 -1
View File
@@ -107,7 +107,7 @@ async def set_prefs(request: Request):
@router.get("/users/search")
async def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions."""
user = _current_user(request)
_current_user(request)
q = (q or "").strip()
with get_conn() as conn:
if q:
+1 -1
View File
@@ -201,7 +201,7 @@ async def list_shares(page_id: int, request: Request):
@router.post("/pages/{page_id}/publish")
async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug."""
user = _require_auth(request)
_require_auth(request)
with get_conn() as conn:
page = conn.execute(
+1 -1
View File
@@ -86,7 +86,7 @@ async def save_sidebar_config(request: Request):
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
config = body.get("config")
if not config or not isinstance(config, dict):
+1 -1
View File
@@ -162,7 +162,7 @@ async def register_webhook(owner: str, repo: str, request: Request):
return {"status": "ok", "webhook": result}
except Exception as e:
logger.error("Failed to register webhook: %s", e)
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e)) from e
@router.get("/status/{owner}/{repo}")
+2 -2
View File
@@ -512,13 +512,13 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
raise HTTPException(404, "Page not found")
try:
cur = conn.execute(
conn.execute(
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
(sid, page_id, status_at_start, velocity_points),
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(409, "Page already assigned to this sprint")
raise HTTPException(409, "Page already assigned to this sprint") from None
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
+1 -1
View File
@@ -166,7 +166,7 @@ class AgentEngine:
used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
try:
for step in range(settings.agent_max_iterations or MAX_ITERATIONS):
for _step in range(settings.agent_max_iterations or MAX_ITERATIONS):
if self._tokens >= settings.agent_max_tokens_budget:
yield self._event("error", {"message": "Budget de tokens dépassé"})
break
+2 -2
View File
@@ -147,7 +147,7 @@ def embed_src(url: str) -> str | None:
u = urlparse(url if url.startswith("http") else "https://" + url)
if u.scheme not in ("http", "https"):
return None
host = (u.hostname or "").lower().replace("www.", "")
(u.hostname or "").lower().replace("www.", "")
netloc = (u.netloc or "").lower()
params = parse_qs(u.query)
# Google Maps share links encode the query in the path (…&q=/maps/…)
@@ -191,4 +191,4 @@ def embed_html(src: str, *, height: int = 520) -> str:
f'style="width:100%;height:{height}px;border:none;border-radius:8px;background:#000;" '
f'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; '
f'picture-in-picture" allowfullscreen></iframe>'
)
)
+3 -2
View File
@@ -243,7 +243,8 @@ def _parse_table_at(lines: list[str], i: int, n: int):
rows.append(cells)
j += 1
width = max(len(r) for r in rows)
pad = lambda r: r + [""] * (width - len(r))
def pad(r):
return r + [""] * (width - len(r))
align = (align + ["left"] * width)[:width]
return (
{
@@ -657,7 +658,7 @@ def blocks_to_html(blocks: list) -> str:
url = b.get("src") or ""
emb = (b.get("embed_type") or "")
if emb in ("inline_dbs", "collection"):
parts.append(f'<div class="embed-note">[Embedded content]</div>')
parts.append('<div class="embed-note">[Embedded content]</div>')
elif emb == "download":
parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>')
elif emb == "pdf" and url:
+1 -1
View File
@@ -94,7 +94,7 @@ def _do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified):
placeholders = ",".join("?" * len(handles))
rows = conn.execute(
"SELECT id, login, email FROM users WHERE lower(login) IN (%s)" % placeholders,
f"SELECT id, login, email FROM users WHERE lower(login) IN ({placeholders})",
handles,
).fetchall()
for row in rows:
+2 -2
View File
@@ -29,7 +29,7 @@ def _extract_og(html: str) -> dict:
text = html[:400_000] # only scan the beginning — that's where <head> lives
props: dict[str, str] = {}
for m in _META_RE.finditer(text):
groups = m.groups()
m.groups()
content = ""
prop = ""
for s in re.findall(r"(?:content|property|name)=[\"']([^\"']*)[\"']", m.group(0)):
@@ -121,4 +121,4 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict:
"image": abs_url(img),
"site_name": site.strip()[:100],
"favicon": favicon,
}
}
+1 -1
View File
@@ -84,4 +84,4 @@ async def trash_purge_scheduler(interval_hours: int = 24):
purge_expired(days=30)
except Exception as exc: # pragma: no cover - defensive only
logger.warning("Trash purge failed: %s", exc)
await asyncio.sleep(interval_hours * 3600)
await asyncio.sleep(interval_hours * 3600)