From ad95c87b019e018063c5cc6ab9f30338f439e695 Mon Sep 17 00:00:00 2001 From: bruno Date: Thu, 9 Jul 2026 23:08:49 -0400 Subject: [PATCH] =?UTF-8?q?feat(v1.9.0):=20My=20Tasks=20=E2=80=94=20dashbo?= =?UTF-8?q?ard=20cross-collection=20unifi=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouveau router my_tasks.py: GET /my-tasks (HTML), GET /my-tasks/api (JSON) - Agrège toutes les pages assignées à l'utilisateur sur toutes les collections - Vues: All, Today, Overdue, Next 7 days - Groupement par collection, filter admin auto - 60/60 tests passent (+4 tests v1.9) - Version 1.8.0 → 1.9.0 --- app/main.py | 7 +- app/routers/api.py | 2 +- app/routers/my_tasks.py | 178 ++++++++++++++++++++++++++++++++++++++++ tests/test_app.py | 46 +++++++++-- 4 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 app/routers/my_tasks.py diff --git a/app/main.py b/app/main.py index 1820e30..2dc0306 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from starlette.middleware.sessions import SessionMiddleware from app.config import settings from app.db import init_db from app.middleware.csrf import CSRFMiddleware -from app.routers import dashboard, board, notes, api, auth, webhooks, collections +from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks from app.services.gitea_client import gitea logging.basicConfig( @@ -31,13 +31,13 @@ async def lifespan(_app: FastAPI): "INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)" ) conn.commit() - logger.info("FlowDeck v1.8.0 started on port %d", settings.app_port) + logger.info("FlowDeck v1.9.0 started on port %d", settings.app_port) yield app = FastAPI( title="FlowDeck", - version="1.8.0", + version="1.9.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, @@ -54,5 +54,6 @@ app.include_router(notes.router) app.include_router(api.router) app.include_router(webhooks.router) app.include_router(collections.router) +app.include_router(my_tasks.router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/app/routers/api.py b/app/routers/api.py index a297668..7395c00 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -57,7 +57,7 @@ async def health(): return { "status": "ok" if (db_ok and gitea_ok) else "degraded", - "version": "1.8.0", + "version": "1.9.0", "db": db_ok, "gitea": gitea_ok, } diff --git a/app/routers/my_tasks.py b/app/routers/my_tasks.py new file mode 100644 index 0000000..c7be634 --- /dev/null +++ b/app/routers/my_tasks.py @@ -0,0 +1,178 @@ +"""FlowDeck — My Tasks router (v1.9.0).""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Request, HTTPException, Query +from fastapi.responses import HTMLResponse + +from app.db import get_conn +from app.auth.session import SessionManager + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["my-tasks"], prefix="/my-tasks") + + +def _get_current_user(request: Request) -> dict | None: + session = request.cookies.get("flowdeck_session", "") + return SessionManager.decode_session(session) + + +@router.get("", response_class=HTMLResponse) +async 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.""" + user = _get_current_user(request) + user_login = user.get("login", "admin") if user else "admin" + + 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() + + collection_tasks = [] + for p in pages: + props = json.loads(p["property_values_json"]) + # Check if assigned to current user + assigned = False + for v in props.values(): + if isinstance(v, list): + for item in v: + if isinstance(item, dict) and item.get("login") == user_login: + assigned = True + break + elif isinstance(v, str) and user_login.lower() in v.lower(): + assigned = True + if assigned: + break + + if not assigned and user_login == "admin": + assigned = True # admin sees all + + if not assigned: + continue + + # Extract status and due date + status = None + due_date = None + for v in props.values(): + if isinstance(v, str) and v in ("Todo", "In Progress", "Done", "Complete"): + status = v + elif isinstance(v, str) and v.startswith("20"): + due_date = v[:10] + + # Filter by view + if view == "today": + from datetime import date + if not due_date or due_date != date.today().isoformat(): + continue + elif view == "overdue": + from datetime import date + if not due_date or due_date >= date.today().isoformat(): + continue + elif view == "upcoming": + from datetime import date, timedelta + if not due_date: + continue + today = date.today() + limit = today + timedelta(days=days) + d = date.fromisoformat(due_date) + if d < today or d > limit: + continue + + collection_tasks.append({ + "id": p["id"], "title": p["title"], "icon": p.get("icon", "📄"), + "status": status, "due_date": due_date, "props": props, + }) + + if collection_tasks: + grouped[col["name"]] = collection_tasks + + total = sum(len(t) for t in grouped.values()) + + # Render + sections = "" + for col_name, tasks in grouped.items(): + items = "" + for t in tasks: + due_badge = f"{t['due_date']}" if t.get("due_date") else "" + status_badge = f"{t.get('status','—')}" + items += f"""
+{t['icon']} +{t['title']} +{status_badge}{due_badge} +
""" + + sections += f"""
+
{col_name} {len(tasks)}
+{items} +
""" + + return HTMLResponse(f""" +My Tasks — FlowDeck + +

📋 My Tasks

+
+All +Today +Overdue +Next 7 days +
+{sections} +

{total} tasks across {len(grouped)} collections

+""") + + +@router.get("/api") +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" + + with get_conn() as conn: + collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall() + result = {} + + for col in collections: + pages = conn.execute( + "SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position", + (col["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, + }) + + if tasks: + result[col["name"]] = tasks + + return {"tasks": result, "total": sum(len(t) for t in result.values())} diff --git a/tests/test_app.py b/tests/test_app.py index 0f3db02..425b005 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -34,7 +34,7 @@ def test_health(client): data = resp.json() assert "status" in data assert data["db"] is True - assert data["version"] == "1.8.0" + assert data["version"] == "1.9.0" def test_dashboard(client): @@ -191,7 +191,7 @@ def test_csrf_protects_sync(client): def test_version_in_health(client): resp = client.get("/api/health") - assert resp.json()["version"] == "1.8.0" + assert resp.json()["version"] == "1.9.0" def test_db_tables_exist(client): @@ -359,7 +359,7 @@ def test_boards_as_collections(client): assert isinstance(data["boards"], list) -# ── v1.8.0: Collection Properties ── +# ── v1.9.0: Collection Properties ── def test_property_types_api(client): """GET /db/property-types/api — should list available types.""" @@ -469,7 +469,7 @@ def test_collection_properties_validation(client): client.delete(f"/db/api/{coll_id}") -# ── v1.8.0: Relations, Rollups, Formulas ── +# ── v1.9.0: Relations, Rollups, Formulas ── def test_create_relation_property(client): """Create a relation property between two collections.""" @@ -607,7 +607,7 @@ def test_formula_empty_expression(client): assert resp.status_code == 400 -# ── v1.8.0: Views (Calendar, Gallery, List, Timeline) ── +# ── v1.9.0: Views (Calendar, Gallery, List, Timeline) ── def test_views_calendar(client): """Calendar view renders with navigation.""" @@ -717,7 +717,7 @@ def test_list_views(client): client.delete(f"/db/api/{cid}") -# ── v1.8.0: Sub-items & Dependencies ── +# ── v1.9.0: Sub-items & Dependencies ── def test_sub_items_crud(client): """Create and list sub-items.""" @@ -796,3 +796,37 @@ def test_dependencies_blocked(client): assert resp.json()["can_transition"] is False assert len(resp.json()["blocked_by"]) == 1 client.delete(f"/db/api/{cid}") + + +# ── v1.9.0: My Tasks ── + +def test_my_tasks_page(client): + """My Tasks dashboard renders.""" + resp = client.get("/my-tasks") + assert resp.status_code == 200 + assert "My Tasks" in resp.text + + +def test_my_tasks_api(client): + """My Tasks API returns JSON.""" + r = client.post("/db/api", json={"name": "My Project"}) + cid = r.json()["id"] + client.post(f"/db/{cid}/pages/api", json={"title": "Task 1", "properties": {"Status": "Todo"}}) + client.post(f"/db/{cid}/pages/api", json={"title": "Task 2", "properties": {"Status": "In Progress"}}) + + resp = client.get("/my-tasks/api") + assert resp.status_code == 200 + data = resp.json() + assert "tasks" in data + assert data["total"] >= 2 + client.delete(f"/db/api/{cid}") + + +def test_my_tasks_view_today(client): + resp = client.get("/my-tasks?view=today") + assert resp.status_code == 200 + + +def test_my_tasks_view_overdue(client): + resp = client.get("/my-tasks?view=overdue") + assert resp.status_code == 200