Complete admin backend with login, where all integrations (weather, news, Home Assistant, Vikunja, Unraid, MQTT) can be configured via web UI instead of ENV variables. Two-layer config: ENV seeds DB on first start, then DB is source of truth. Auto-migration system on startup. Backend: db.py shared pool, auth.py JWT, settings_service CRUD, seed_service, admin router (protected), test_connections per integration, config.py rewrite. Frontend: react-router v6, login page, admin layout with sidebar, 8 settings pages (General, Weather, News, HA, Vikunja, Unraid, MQTT, ChangePassword), shared IntegrationForm + TestButton components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
93 lines
2.5 KiB
Python
93 lines
2.5 KiB
Python
"""News service — queries market_news from PostgreSQL via shared pool."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncpg
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from server.db import get_pool
|
|
|
|
|
|
def _row_to_dict(row: asyncpg.Record) -> Dict[str, Any]:
|
|
"""Convert an asyncpg Record to a plain dictionary with JSON-safe values."""
|
|
d: Dict[str, Any] = dict(row)
|
|
if "published_at" in d and d["published_at"] is not None:
|
|
d["published_at"] = d["published_at"].isoformat()
|
|
return d
|
|
|
|
|
|
async def get_news(
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
category: Optional[str] = None,
|
|
max_age_hours: int = 48,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Fetch recent news articles from the market_news table."""
|
|
pool = await get_pool()
|
|
|
|
params: List[Any] = []
|
|
param_idx = 1
|
|
|
|
base_query = (
|
|
"SELECT id, source, title, url, category, published_at "
|
|
"FROM market_news "
|
|
f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours'"
|
|
)
|
|
|
|
if category is not None:
|
|
base_query += f" AND category = ${param_idx}"
|
|
params.append(category)
|
|
param_idx += 1
|
|
|
|
base_query += f" ORDER BY published_at DESC LIMIT ${param_idx} OFFSET ${param_idx + 1}"
|
|
params.append(limit)
|
|
params.append(offset)
|
|
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch(base_query, *params)
|
|
|
|
return [_row_to_dict(row) for row in rows]
|
|
|
|
|
|
async def get_news_count(
|
|
max_age_hours: int = 48,
|
|
category: Optional[str] = None,
|
|
) -> int:
|
|
"""Return the total count of recent news articles."""
|
|
pool = await get_pool()
|
|
|
|
params: List[Any] = []
|
|
param_idx = 1
|
|
|
|
query = (
|
|
"SELECT COUNT(*) AS cnt "
|
|
"FROM market_news "
|
|
f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours'"
|
|
)
|
|
|
|
if category is not None:
|
|
query += f" AND category = ${param_idx}"
|
|
params.append(category)
|
|
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(query, *params)
|
|
|
|
return int(row["cnt"]) if row else 0
|
|
|
|
|
|
async def get_categories(max_age_hours: int = 48) -> List[str]:
|
|
"""Return distinct categories from recent news articles."""
|
|
pool = await get_pool()
|
|
|
|
query = (
|
|
"SELECT DISTINCT category "
|
|
"FROM market_news "
|
|
f"WHERE published_at > NOW() - INTERVAL '{int(max_age_hours)} hours' "
|
|
"AND category IS NOT NULL "
|
|
"ORDER BY category"
|
|
)
|
|
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch(query)
|
|
|
|
return [row["category"] for row in rows]
|