Table today_picks; POST /api/today/pin (max 3, idempotent) et
/unpin; page /today (picks en direct, candidates de l'encours,
reprises d'hier, récap du jour depuis le journal); bouton 📌 sur
les cartes (délégation capture dans today.js, fragments live
compris); sections dédiées dans le briefing du matin.
88 lines
3.4 KiB
Python
88 lines
3.4 KiB
Python
"""Daily briefing: formatting and ntfy delivery.
|
||
|
||
The data itself is gathered in app.main (it needs the board helpers);
|
||
this module only turns it into a readable text and pushes it to an
|
||
ntfy topic. Kept dependency-light so it is unit-testable alone.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
|
||
from app.config import Settings
|
||
|
||
|
||
def ntfy_configured(settings: Settings) -> bool:
|
||
return bool(settings.ntfy_url and settings.ntfy_topic)
|
||
|
||
|
||
def format_briefing_text(data: dict) -> str:
|
||
"""Plain-text briefing (ntfy body, also shown on /briefing)."""
|
||
lines: list[str] = [f"📋 Kanbante — briefing du {data['date']}"]
|
||
activity = data["activity_24h"]
|
||
lines.append(
|
||
f"\n⚡ Dernières 24 h : {activity['closed']} fermée(s), "
|
||
f"{activity['created']} créée(s), {activity['moved']} déplacement(s)."
|
||
)
|
||
if activity.get("avg_cycle_days") is not None:
|
||
lines.append(f"⏱️ Temps de cycle moyen (30 j) : {activity['avg_cycle_days']} j.")
|
||
|
||
if data.get("today_picks"):
|
||
lines.append("\n🌅 Focus du jour :")
|
||
for pick in data["today_picks"]:
|
||
mark = "✅" if pick["state"] == "closed" else "📌"
|
||
lines.append(
|
||
f" {mark} {pick['repo']} #{pick['number']} « {pick['title']} »"
|
||
f" — {pick['column_label']}"
|
||
)
|
||
if data.get("carry_over"):
|
||
lines.append("\n↩️ À reprendre d'hier (non terminées) :")
|
||
for pick in data["carry_over"]:
|
||
lines.append(
|
||
f" • {pick['repo']} #{pick['number']} « {pick['title']} »"
|
||
f" — {pick['column_label']}"
|
||
)
|
||
|
||
lines.append(f"\n🎯 En cours / en révision : {data['focus_total']} carte(s)")
|
||
for group in data["focus_groups"][:6]:
|
||
lines.append(f" • {group['full_name']} : {group['count']}")
|
||
for title in group["titles"][:3]:
|
||
lines.append(f" – {title}")
|
||
|
||
if data["aging"]:
|
||
lines.append("\n⏳ Cartes qui vieillissent :")
|
||
for card in data["aging"][:8]:
|
||
lines.append(
|
||
f" • {card['repo']} #{card['number']} « {card['title']} » "
|
||
f"— {card['days']} j en {card['column_label']}"
|
||
)
|
||
|
||
if data["milestones_due"]:
|
||
lines.append("\n📅 Milestones proches :")
|
||
for ms in data["milestones_due"][:5]:
|
||
lines.append(
|
||
f" • {ms['repo']} « {ms['title']} » — échéance "
|
||
f"{ms['due_on']} ({ms['open']} ouverte(s))"
|
||
)
|
||
|
||
if data["focus_total"] == 0 and not data["aging"]:
|
||
lines.append("\n🎉 Rien d'urgent : aucun encours, aucune carte vieillissante.")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def send_ntfy(settings: Settings, text: str) -> bool:
|
||
"""POST the briefing to the configured ntfy topic. True on 2xx."""
|
||
if not ntfy_configured(settings):
|
||
return False
|
||
url = f"{settings.ntfy_url.rstrip('/')}/{settings.ntfy_topic}"
|
||
headers = {
|
||
# ntfy headers must stay ASCII; the body carries the accents.
|
||
"Title": "Kanbante - briefing du jour",
|
||
"Tags": "clipboard,chart_with_upwards_trend",
|
||
}
|
||
if settings.ntfy_token:
|
||
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
response = await client.post(url, content=text.encode("utf-8"), headers=headers)
|
||
return response.status_code < 300
|