From ae3dff095225e55a511c28d552c87ff41f3b9af0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 11 Mar 2026 19:46:04 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20briefing=20API=20routes=20=E2=80=94=20f?= =?UTF-8?q?eeds=20CRUD,=20weather,=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/app.py | 2 + src/fabledassistant/routes/briefing.py | 166 +++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 src/fabledassistant/routes/briefing.py diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 21c3e25..359ed0b 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -11,6 +11,7 @@ from fabledassistant.config import Config from fabledassistant.routes.admin import admin_bp from fabledassistant.routes.api import api from fabledassistant.routes.auth import auth_bp +from fabledassistant.routes.briefing import briefing_bp from fabledassistant.routes.chat import chat_bp from fabledassistant.routes.export import export_bp from fabledassistant.routes.notes import notes_bp @@ -60,6 +61,7 @@ def create_app() -> Quart: app.register_blueprint(admin_bp) app.register_blueprint(api) app.register_blueprint(auth_bp) + app.register_blueprint(briefing_bp) app.register_blueprint(chat_bp) app.register_blueprint(export_bp) app.register_blueprint(images_bp) diff --git a/src/fabledassistant/routes/briefing.py b/src/fabledassistant/routes/briefing.py new file mode 100644 index 0000000..9ddb1c5 --- /dev/null +++ b/src/fabledassistant/routes/briefing.py @@ -0,0 +1,166 @@ +"""Briefing API: RSS feeds, weather, and briefing configuration.""" + +import asyncio +import json +import logging + +from quart import Blueprint, g, jsonify, request +from sqlalchemy import select + +from fabledassistant.auth import login_required +from fabledassistant.models import async_session +from fabledassistant.models.rss_feed import RssFeed +from fabledassistant.services import rss as rss_svc +from fabledassistant.services import weather as weather_svc +from fabledassistant.services.settings import get_setting, set_settings_batch + +logger = logging.getLogger(__name__) +briefing_bp = Blueprint("briefing", __name__, url_prefix="/api/briefing") + +_REQUIRE = login_required + + +# ── Config ──────────────────────────────────────────────────────────────────── + +@briefing_bp.route("/config", methods=["GET"]) +@_REQUIRE +async def get_config(): + raw = await get_setting(g.user.id, "briefing_config", "{}") + try: + config = json.loads(raw) if isinstance(raw, str) else (raw or {}) + except Exception: + config = {} + return jsonify(config) + + +@briefing_bp.route("/config", methods=["PUT"]) +@_REQUIRE +async def put_config(): + data = await request.get_json() + await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)}) + return jsonify({"ok": True}) + + +# ── RSS Feeds ───────────────────────────────────────────────────────────────── + +@briefing_bp.route("/feeds", methods=["GET"]) +@_REQUIRE +async def list_feeds(): + async with async_session() as session: + result = await session.execute( + select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id) + ) + feeds = list(result.scalars().all()) + return jsonify([f.to_dict() for f in feeds]) + + +@briefing_bp.route("/feeds", methods=["POST"]) +@_REQUIRE +async def add_feed(): + data = await request.get_json() + url = (data.get("url") or "").strip() + if not url: + return jsonify({"error": "url required"}), 400 + category = data.get("category") or None + + async with async_session() as session: + # Prevent duplicates + existing = await session.execute( + select(RssFeed).where(RssFeed.user_id == g.user.id, RssFeed.url == url) + ) + if existing.scalars().first(): + return jsonify({"error": "Feed already added"}), 409 + + feed = RssFeed(user_id=g.user.id, url=url, title="", category=category) + session.add(feed) + await session.commit() + await session.refresh(feed) + feed_id = feed.id + + # Fetch in background to auto-populate title and get initial items + asyncio.create_task(rss_svc.fetch_and_cache_feed(feed_id, url)) + + return jsonify({"id": feed_id, "url": url, "title": "", "category": category}), 201 + + +@briefing_bp.route("/feeds/", methods=["DELETE"]) +@_REQUIRE +async def delete_feed(feed_id: int): + async with async_session() as session: + result = await session.execute( + select(RssFeed).where(RssFeed.id == feed_id, RssFeed.user_id == g.user.id) + ) + feed = result.scalars().first() + if not feed: + return jsonify({"error": "Not found"}), 404 + await session.delete(feed) + await session.commit() + return jsonify({"ok": True}) + + +@briefing_bp.route("/feeds/refresh", methods=["POST"]) +@_REQUIRE +async def refresh_feeds(): + results = await rss_svc.refresh_all_feeds(g.user.id) + total_new = sum(results.values()) + return jsonify({"feeds_refreshed": len(results), "new_items": total_new}) + + +@briefing_bp.route("/feeds/recent", methods=["GET"]) +@_REQUIRE +async def recent_items(): + limit = min(int(request.args.get("limit", 20)), 100) + items = await rss_svc.get_recent_items(g.user.id, limit=limit) + return jsonify({"items": items}) + + +# ── Weather ─────────────────────────────────────────────────────────────────── + +@briefing_bp.route("/weather", methods=["GET"]) +@_REQUIRE +async def get_weather(): + data = await weather_svc.get_cached_weather(g.user.id) + return jsonify({"locations": data}) + + +@briefing_bp.route("/weather/geocode", methods=["POST"]) +@_REQUIRE +async def geocode_location(): + data = await request.get_json() + query = (data.get("query") or "").strip() + if not query: + return jsonify({"error": "query required"}), 400 + try: + lat, lon, label = await weather_svc.geocode(query) + return jsonify({"lat": lat, "lon": lon, "label": label}) + except ValueError as e: + return jsonify({"error": str(e)}), 404 + + +@briefing_bp.route("/weather/refresh", methods=["POST"]) +@_REQUIRE +async def refresh_weather(): + raw = await get_setting(g.user.id, "briefing_config", "{}") + try: + config = json.loads(raw) if isinstance(raw, str) else {} + except Exception: + config = {} + + locations = config.get("locations", {}) + refreshed = [] + for key, loc in locations.items(): + if not loc.get("lat") or not loc.get("lon"): + continue + try: + await weather_svc.refresh_location_cache( + user_id=g.user.id, + location_key=key, + location_label=loc.get("label", key), + lat=loc["lat"], + lon=loc["lon"], + ) + refreshed.append(key) + except Exception: + logger.warning("Failed to refresh weather for %s", key, exc_info=True) + + return jsonify({"refreshed": refreshed})