CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 24s
- background.start_periodic(interval, work, label=) replaces the three hand-rolled while-True/sleep/try loops in logging, auth and notifications. - services/scheduler.ScheduledJob replaces the four private BackgroundScheduler copies in recurrence/version_pinning/trash/db_maintenance schedulers; public start_/stop_/reschedule_ surfaces unchanged. - api_keys.hash_token is the one sha256 helper; auth.py used to inline it 5x. - auth.is_registration_open reads via settings.get_admin_setting; notification prefs read via settings.get_setting; _fire_share_email uses _get_user_email. - projects.get_project_summary / milestones.get_project_milestone_summary are now the one-id view of their batch siblings instead of a second copy of the queries; sharing.best_permission_by (was _deduplicate_by_permission) is the one rank-dedup, now also used by list_projects_for_user. - backup: the row builders for every section both exporters carry are named functions, so a column added to one export cannot silently miss the other. - iso() from models.base replaces the attr.isoformat()-if-attr-else-None idiom and db_maintenance._iso across services; backup keeps its explicit shape. - trash.py hoists the sql_delete/timedelta imports it re-imported per function. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
193 lines
7.9 KiB
Python
193 lines
7.9 KiB
Python
"""Dashboard aggregation — assembles the /dashboard landing payload.
|
|
|
|
One call: most-recently-active projects (each -> active milestones -> open
|
|
tasks), recently-completed tasks, week stats. Owner-scoped,
|
|
trashed rows excluded. Each section is independent — a failure returns its
|
|
empty value rather than blanking the page.
|
|
"""
|
|
from __future__ import annotations
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import case, func, select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.note import Note
|
|
from scribe.models.project import Project
|
|
from scribe.models.milestone import Milestone
|
|
from scribe.models.base import iso
|
|
from scribe.services import milestones as milestones_svc
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
N_PROJECTS = 3 # most-recently-active projects shown
|
|
TASKS_PER_GROUP = 5 # open-task cap per milestone / no-milestone group
|
|
RECENT_DONE_LIMIT = 8 # recently-completed tasks shown
|
|
OPEN_ISSUES_LIMIT = 10 # open issues shown on the dashboard
|
|
WINDOW_DAYS = 7 # look-back window (done items, week stats)
|
|
_OPEN = ["todo", "in_progress"]
|
|
|
|
|
|
def _open_order():
|
|
"""in-progress first -> priority high..none -> most-recently-updated."""
|
|
status_rank = case((Note.status == "in_progress", 0), else_=1)
|
|
priority_rank = case(
|
|
(Note.priority == "high", 0), (Note.priority == "medium", 1),
|
|
(Note.priority == "low", 2), else_=3,
|
|
)
|
|
return status_rank, priority_rank, Note.updated_at.desc()
|
|
|
|
|
|
def _task_row(n: Note) -> dict:
|
|
return {"id": n.id, "title": n.title, "status": n.status,
|
|
"priority": n.priority or "none"}
|
|
|
|
|
|
async def _safe(coro, empty):
|
|
try:
|
|
return await coro
|
|
except Exception:
|
|
logger.warning("dashboard section failed", exc_info=True)
|
|
return empty
|
|
|
|
|
|
async def _open_issues(user_id: int) -> list[dict]:
|
|
"""Open issues (task_kind='issue', not done/cancelled) across the owner's
|
|
projects, ranked like the other task lists. Owner-scoped, matching the rest
|
|
of the dashboard."""
|
|
async with async_session() as session:
|
|
rows = await session.execute(
|
|
select(
|
|
Note.id, Note.title, Note.status, Note.priority,
|
|
Note.project_id, Project.title,
|
|
)
|
|
.join(Project, Project.id == Note.project_id, isouter=True)
|
|
.where(
|
|
Note.user_id == user_id,
|
|
Note.task_kind == "issue",
|
|
Note.status.in_(_OPEN),
|
|
Note.deleted_at.is_(None),
|
|
)
|
|
.order_by(*_open_order())
|
|
.limit(OPEN_ISSUES_LIMIT)
|
|
)
|
|
return [
|
|
{
|
|
"id": iid, "title": title, "status": status,
|
|
"priority": priority or "none",
|
|
"project_id": pid, "project_title": pname,
|
|
}
|
|
for iid, title, status, priority, pid, pname in rows.fetchall()
|
|
]
|
|
|
|
|
|
async def build_dashboard(user_id: int) -> dict:
|
|
return {
|
|
"active_projects": await _safe(_active_projects(user_id), []),
|
|
"recently_completed": await _safe(_recently_completed(user_id), []),
|
|
"open_issues": await _safe(_open_issues(user_id), []),
|
|
"week_stats": await _safe(_week_stats(user_id), {}),
|
|
}
|
|
|
|
|
|
async def _active_projects(user_id: int) -> list[dict]:
|
|
so, po, ro = _open_order()
|
|
async with async_session() as session:
|
|
recency = (
|
|
select(Note.project_id, func.max(Note.updated_at).label("last"))
|
|
.where(Note.user_id == user_id, Note.deleted_at.is_(None),
|
|
Note.project_id.isnot(None))
|
|
.group_by(Note.project_id).subquery()
|
|
)
|
|
prows = (await session.execute(
|
|
select(Project, recency.c.last)
|
|
.outerjoin(recency, Project.id == recency.c.project_id)
|
|
.where(Project.user_id == user_id, Project.status == "active",
|
|
Project.deleted_at.is_(None))
|
|
.order_by(func.coalesce(recency.c.last, Project.updated_at).desc())
|
|
.limit(N_PROJECTS)
|
|
)).all()
|
|
|
|
out = []
|
|
for project, last in prows:
|
|
counts = (await session.execute(
|
|
select(
|
|
func.count(Note.id).filter(Note.status.in_(_OPEN)),
|
|
func.count(Note.id).filter(Note.status == "done"),
|
|
func.count(Note.id).filter(Note.status.in_(_OPEN + ["done"])),
|
|
).where(Note.user_id == user_id, Note.project_id == project.id,
|
|
Note.deleted_at.is_(None))
|
|
)).one()
|
|
open_count, done_count, resolved_total = counts
|
|
progress_pct = round(done_count / resolved_total * 100, 1) if resolved_total else 0.0
|
|
|
|
mrows = (await session.execute(
|
|
select(Milestone).where(
|
|
Milestone.user_id == user_id, Milestone.project_id == project.id,
|
|
Milestone.status == "active", Milestone.deleted_at.is_(None))
|
|
.order_by(Milestone.order_index.asc())
|
|
)).scalars().all()
|
|
milestones = []
|
|
for m in mrows:
|
|
tasks = (await session.execute(
|
|
select(Note).where(
|
|
Note.user_id == user_id, Note.milestone_id == m.id,
|
|
Note.status.in_(_OPEN), Note.deleted_at.is_(None))
|
|
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
|
|
)).scalars().all()
|
|
if not tasks:
|
|
continue
|
|
prog = await milestones_svc.get_milestone_progress(m.id)
|
|
milestones.append({
|
|
"id": m.id, "title": m.title, "progress_pct": prog["pct"],
|
|
"open_tasks": [_task_row(t) for t in tasks],
|
|
})
|
|
|
|
no_ms = (await session.execute(
|
|
select(Note).where(
|
|
Note.user_id == user_id, Note.project_id == project.id,
|
|
Note.milestone_id.is_(None), Note.status.in_(_OPEN),
|
|
Note.deleted_at.is_(None))
|
|
.order_by(so, po, ro).limit(TASKS_PER_GROUP)
|
|
)).scalars().all()
|
|
|
|
out.append({
|
|
"id": project.id, "title": project.title, "color": project.color,
|
|
"last_activity": (last or project.updated_at).isoformat(),
|
|
"open_count": open_count, "done_count": done_count,
|
|
"progress_pct": progress_pct,
|
|
"milestones": milestones,
|
|
"no_milestone": [_task_row(t) for t in no_ms],
|
|
})
|
|
return out
|
|
|
|
|
|
async def _recently_completed(user_id: int) -> list[dict]:
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
|
|
async with async_session() as session:
|
|
rows = (await session.execute(
|
|
select(Note, Project.title)
|
|
.outerjoin(Project, Note.project_id == Project.id)
|
|
.where(Note.user_id == user_id, Note.status == "done",
|
|
Note.deleted_at.is_(None), Note.completed_at.isnot(None),
|
|
Note.completed_at >= cutoff)
|
|
.order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT)
|
|
)).all()
|
|
return [{"id": n.id, "title": n.title, "project_title": ptitle,
|
|
"completed_at": iso(n.completed_at)} for n, ptitle in rows]
|
|
|
|
|
|
async def _week_stats(user_id: int) -> dict:
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)
|
|
async with async_session() as session:
|
|
row = (await session.execute(
|
|
select(
|
|
func.count(Note.id).filter(Note.status == "done", Note.completed_at >= cutoff),
|
|
func.count(Note.id).filter(Note.status.in_(_OPEN)),
|
|
func.count(Note.id).filter(Note.status == "in_progress"),
|
|
func.count(Note.id).filter(Note.task_kind == "plan", Note.status.in_(_OPEN)),
|
|
).where(Note.user_id == user_id, Note.deleted_at.is_(None))
|
|
)).one()
|
|
return {"completed_this_week": row[0], "open_total": row[1],
|
|
"in_progress": row[2], "active_plans": row[3]}
|