Add Projects, Milestones, RAG auto-inject, push notifications, PWA, tag normalisation
## Projects & Milestones (Phases A + G) - New models: Project, Milestone (Project → Milestone → Task hierarchy) - notes table: project_id + milestone_id FKs; parent_id FK constraint activated - Migrations: 0017 (projects), 0018 (push_subscriptions), 0019 (events), 0020 (milestones) - Services: projects.py, milestones.py (CRUD + progress tracking) - Routes: /api/projects + /api/projects/<id>/milestones - LLM tools: create/list/get/update project; create/list milestone; project + milestone + parent_task params on note/task tools - Frontend: ProjectListView (stacked milestone bars), ProjectView (milestone-grouped kanban), ProjectSelector, MilestoneSelector, NoteEditorView + TaskEditorView updated ## RAG Auto-injection (Phase B) - Notes ≥0.60 cosine similarity auto-injected into system prompt (max 3, 800 chars each) - excluded_note_ids param; ChatView "Auto-included" sidebar section ## Summarisation improvements (Phase C) - Threshold 20→30, keep-recent 6→8, max_tokens 200→400 - Two-pass summarisation for histories >50 messages ## Browser push notifications (Phase E) - PushSubscription model + migration; pywebpush dependency - /api/push routes; VAPID config; fire-and-forget on generation complete - Frontend: sw.js, push store, Settings toggle ## PWA manifest (Phase F) - manifest.json, Apple meta tags, service worker registration in main.ts ## Tag normalisation - All tags lowercased + deduplicated at backend (create_note/update_note) and frontend (TagInput sanitize) - Note/Task types gain project_id + milestone_id fields; store signatures updated ## CalDAV - Radicale embedded server reverted; back to user-configured external CalDAV Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"""Browser push notification service using VAPID/pywebpush."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.push_subscription import PushSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_webpush():
|
||||
"""Lazy import to avoid startup errors if pywebpush is not installed."""
|
||||
try:
|
||||
from pywebpush import WebPusher
|
||||
return WebPusher
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def vapid_enabled() -> bool:
|
||||
return bool(Config.VAPID_PRIVATE_KEY and Config.VAPID_PUBLIC_KEY)
|
||||
|
||||
|
||||
async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription:
|
||||
"""Upsert a push subscription by endpoint."""
|
||||
endpoint = subscription_json.get("endpoint", "")
|
||||
keys = subscription_json.get("keys", {})
|
||||
p256dh = keys.get("p256dh", "")
|
||||
auth = keys.get("auth", "")
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(PushSubscription).where(
|
||||
PushSubscription.user_id == user_id,
|
||||
PushSubscription.endpoint == endpoint,
|
||||
)
|
||||
)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
existing.p256dh = p256dh
|
||||
existing.auth = auth
|
||||
existing.user_agent = user_agent
|
||||
existing.last_used = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
else:
|
||||
sub = PushSubscription(
|
||||
user_id=user_id,
|
||||
endpoint=endpoint,
|
||||
p256dh=p256dh,
|
||||
auth=auth,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
session.add(sub)
|
||||
await session.commit()
|
||||
await session.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
async def delete_subscription(user_id: int, endpoint: str) -> None:
|
||||
async with async_session() as session:
|
||||
await session.execute(
|
||||
delete(PushSubscription).where(
|
||||
PushSubscription.user_id == user_id,
|
||||
PushSubscription.endpoint == endpoint,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _remove_expired_subscription(user_id: int, endpoint: str) -> None:
|
||||
"""Remove a subscription that returned 410 Gone."""
|
||||
try:
|
||||
await delete_subscription(user_id, endpoint)
|
||||
logger.info("Removed expired push subscription for user %d", user_id)
|
||||
except Exception:
|
||||
logger.warning("Failed to remove expired subscription", exc_info=True)
|
||||
|
||||
|
||||
async def send_push_notification(
|
||||
user_id: int,
|
||||
title: str,
|
||||
body: str,
|
||||
url: str = "/",
|
||||
) -> None:
|
||||
"""Send a push notification to all subscriptions for a user.
|
||||
|
||||
Fire-and-forget — wrap in asyncio.create_task() at call site.
|
||||
"""
|
||||
if not vapid_enabled():
|
||||
logger.debug("VAPID not configured, skipping push notification")
|
||||
return
|
||||
|
||||
WebPusher = _get_webpush()
|
||||
if WebPusher is None:
|
||||
logger.warning("pywebpush not installed, cannot send push notifications")
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(PushSubscription).where(PushSubscription.user_id == user_id)
|
||||
)
|
||||
subscriptions = list(result.scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return
|
||||
|
||||
payload = json.dumps({"title": title, "body": body, "url": url})
|
||||
vapid_claims = {
|
||||
"sub": Config.VAPID_CLAIMS_SUB,
|
||||
}
|
||||
|
||||
def _send_sync(sub: PushSubscription) -> tuple[int, str]:
|
||||
"""Synchronous send — runs in executor."""
|
||||
try:
|
||||
subscription_info = {
|
||||
"endpoint": sub.endpoint,
|
||||
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
|
||||
}
|
||||
response = WebPusher(subscription_info).send(
|
||||
data=payload,
|
||||
vapid_private_key=Config.VAPID_PRIVATE_KEY,
|
||||
vapid_claims=vapid_claims,
|
||||
)
|
||||
return response.status_code, sub.endpoint
|
||||
except Exception as e:
|
||||
logger.warning("Push send failed for sub %d: %s", sub.id, e)
|
||||
return 0, sub.endpoint
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
tasks = [loop.run_in_executor(None, _send_sync, sub) for sub in subscriptions]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for sub, result in zip(subscriptions, results):
|
||||
if isinstance(result, Exception):
|
||||
continue
|
||||
status_code, endpoint = result
|
||||
if status_code == 410:
|
||||
asyncio.create_task(_remove_expired_subscription(user_id, endpoint))
|
||||
elif status_code and status_code not in (200, 201):
|
||||
logger.warning("Push returned status %d for user %d", status_code, user_id)
|
||||
Reference in New Issue
Block a user