276 lines
8.9 KiB
Python
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()
|