Files
kadence/backend/app/services/tasks_service.py
T
2026-10-18 18:02:05 +03:00

276 lines
8.9 KiB
Python

"""The task list: refreshing it from every source, and acting on it.
External tasks are mirrored into the `tasks` table on each refresh. The mirror
buys three things a live call cannot: the UI stays instant, the daily report
still works when a source is down, and "I finished this" is recorded locally
even if the write-back to Jira fails.
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Any, Iterable
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import TaskRecord, utcnow
from app.settings_schema import AppSettings
from app.sources.base import SourceResult, TodoItem
from app.sources.registry import SOURCE_LABELS, complete_remote, fetch_all
from app.timeutil import local_date
log = logging.getLogger(__name__)
LOCAL_SOURCE = "local"
# --------------------------------------------------------------------------
# Refresh
# --------------------------------------------------------------------------
def _upsert(session: Session, item: TodoItem) -> TaskRecord:
record = session.scalar(
select(TaskRecord).where(
TaskRecord.source == item.source,
TaskRecord.external_id == item.external_id,
)
)
if record is None:
record = TaskRecord(source=item.source, external_id=item.external_id)
session.add(record)
record.title = item.title
record.description = item.description
record.url = item.url
record.project = item.project
record.status = item.status
record.priority = item.priority
record.due_date = item.due_date
record.start_date = item.start_date
record.labels = item.labels
record.raw = item.raw
record.last_seen_at = utcnow()
record.stale = False
if item.is_done and not record.is_done:
# Closed in the source, not here: it counts as done but not as "mine".
record.is_done = True
record.completed_at = record.completed_at or utcnow()
return record
def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
"""Pull every enabled source and reconcile the mirror.
Tasks a source stops returning are flagged `stale` rather than deleted: the
history behind "you finished 6 things today" has to survive a source that
quietly drops an item.
"""
results: list[SourceResult] = fetch_all(settings)
today = local_date(settings.timezone)
summary: list[dict[str, Any]] = []
for result in results:
if not result.ok:
summary.append(
{
"source": result.source,
"label": SOURCE_LABELS.get(result.source, result.source),
"ok": False,
"error": result.error,
"count": 0,
}
)
continue
seen_ids: set[str] = set()
for item in result.items:
_upsert(session, item)
seen_ids.add(item.external_id)
existing = session.scalars(
select(TaskRecord).where(
TaskRecord.source == result.source, TaskRecord.is_done.is_(False)
)
).all()
for record in existing:
if record.external_id not in seen_ids:
record.stale = True
summary.append(
{
"source": result.source,
"label": SOURCE_LABELS.get(result.source, result.source),
"ok": True,
"error": None,
"count": len(result.items),
}
)
session.commit()
return {
"refreshed_at": utcnow().isoformat(),
"date": today.isoformat(),
"sources": summary,
"total": sum(entry["count"] for entry in summary),
}
# --------------------------------------------------------------------------
# Queries
# --------------------------------------------------------------------------
def list_tasks(
session: Session,
*,
include_done: bool = False,
include_stale: bool = False,
sources: Iterable[str] | None = None,
) -> list[TaskRecord]:
query = select(TaskRecord)
if not include_done:
query = query.where(TaskRecord.is_done.is_(False))
if not include_stale:
query = query.where(TaskRecord.stale.is_(False))
if sources:
query = query.where(TaskRecord.source.in_(list(sources)))
records = list(session.scalars(query))
records.sort(key=_sort_key)
return records
def _sort_key(record: TaskRecord) -> tuple:
"""Pinned first, then by deadline (undated last), then by source and title."""
return (
not record.pinned,
record.due_date is None,
record.due_date or date.max,
record.source,
(record.title or "").lower(),
)
def bucket_tasks(records: Iterable[TaskRecord], today: date) -> dict[str, list[TaskRecord]]:
"""Group a task list the way the UI and the report both want it."""
buckets: dict[str, list[TaskRecord]] = {
"overdue": [],
"today": [],
"upcoming": [],
"someday": [],
}
for record in records:
if record.due_date is None:
buckets["someday"].append(record)
elif record.due_date < today:
buckets["overdue"].append(record)
elif record.due_date == today:
buckets["today"].append(record)
else:
buckets["upcoming"].append(record)
return buckets
def completed_on(session: Session, day: date, *, only_here: bool = True) -> list[TaskRecord]:
query = select(TaskRecord).where(TaskRecord.completed_date == day)
if only_here:
query = query.where(TaskRecord.completed_here.is_(True))
return list(session.scalars(query))
# --------------------------------------------------------------------------
# Mutations
# --------------------------------------------------------------------------
def create_local_task(
session: Session,
*,
title: str,
description: str | None = None,
due_date: date | None = None,
priority: str | None = None,
project: str | None = None,
estimated_pomodoros: int | None = None,
) -> TaskRecord:
"""A task that lives only here — the quick "and this too" you type at 9am."""
# A random id, not a timestamp: two tasks added in the same millisecond
# (a double-clicked Add button) would otherwise collide on the unique key.
external_id = f"local-{uuid4().hex}"
record = TaskRecord(
source=LOCAL_SOURCE,
external_id=external_id,
title=title.strip(),
description=description,
due_date=due_date,
priority=priority,
project=project or "My list",
estimated_pomodoros=estimated_pomodoros,
)
session.add(record)
session.commit()
return record
def complete_task(
session: Session, settings: AppSettings, task_id: int, *, done: bool = True
) -> tuple[TaskRecord, str | None]:
"""Mark a task done (or undone) here, and try to mirror it to its source.
Returns the record plus a warning when the write-back failed — the local
state still changes, because the alternative is losing the fact that you
finished something because Jira hiccupped.
"""
record = session.get(TaskRecord, task_id)
if record is None:
raise LookupError(f"no task {task_id}")
warning: str | None = None
if done:
record.is_done = True
record.completed_here = True
record.completed_at = utcnow()
record.completed_date = local_date(settings.timezone)
if record.source != LOCAL_SOURCE:
item = TodoItem(
source=record.source,
external_id=record.external_id,
title=record.title,
raw=record.raw or {},
)
wrote, error = complete_remote(settings, item)
if error:
warning = (
f"Marked done here, but {SOURCE_LABELS.get(record.source, record.source)}"
f" did not accept it: {error}"
)
elif not wrote:
warning = (
f"Marked done here only — write-back is off for "
f"{SOURCE_LABELS.get(record.source, record.source)}."
)
else:
record.is_done = False
record.completed_here = False
record.completed_at = None
record.completed_date = None
session.commit()
return record, warning
def update_task(session: Session, task_id: int, **changes: Any) -> TaskRecord:
record = session.get(TaskRecord, task_id)
if record is None:
raise LookupError(f"no task {task_id}")
for key, value in changes.items():
if value is not None and hasattr(record, key):
setattr(record, key, value)
session.commit()
return record
def delete_task(session: Session, task_id: int) -> None:
record = session.get(TaskRecord, task_id)
if record is None:
return
session.delete(record)
session.commit()