venthrive-api / routes /news.py
GitHub Actions
Sync from GitHub · 2026-10-21 11:53 UTC
b7c46f4
Raw History Blame Contribute Delete
8.53 kB
"""
Business News Routes (ADR-0015)
──────────────────────────────────────────────────────────────────────────────
Routes:
POST /api/news/poll → admin-triggered scrape+classify run (max 30 days back)
GET /api/news/admin/polls → admin: recent poll runs and their outcome
GET /api/news/items → classified, unexpired items (optionally filtered by business type)
GET /api/news/admin/review → admin: items flagged incomplete, awaiting a human fix
PATCH /api/news/admin/review/{id} → admin: manually complete or reject a flagged item
Polling runs as a background task (see news/pipeline.py) because scraping every
source and classifying each new item through Groq takes minutes — far past any
gateway's request timeout. POST /poll returns as soon as the run is recorded;
the frontend watches GET /admin/polls for it to finish.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from pydantic import BaseModel, Field
from firebase_admin import firestore
from auth import verify_firebase_token
from news.pipeline import start_poll, execute_poll
from news.classify import IMPACTS, CATEGORIES
from news.business_types import valid_subset
from utils.supabase_client import get_supabase
from utils.audit import log_action
from intel.events import record_news_item
router = APIRouter()
def _verify_admin(user: dict) -> str:
uid = user.get("uid")
if not uid:
raise HTTPException(status_code=401, detail="Unauthorized.")
db = firestore.client()
doc = db.collection("user_data").document(uid).get()
role = doc.to_dict().get("role", "user") if doc.exists else "user"
if role != "admin":
raise HTTPException(status_code=403, detail="Admin access required.")
return uid
class PollRequest(BaseModel):
since_days: int = Field(default=7, ge=1, le=30)
@router.post("/poll")
def poll_news(body: PollRequest, background_tasks: BackgroundTasks, user: dict = Depends(verify_firebase_token)):
admin_uid = _verify_admin(user)
poll_id = start_poll(since_days=body.since_days, triggered_by=admin_uid)
background_tasks.add_task(execute_poll, poll_id, body.since_days)
log_action(admin_uid, "run", "started", f"Looking back {body.since_days} day(s)", target=poll_id)
return {"poll_id": poll_id, "started": True}
@router.get("/admin/overview")
def admin_overview(user: dict = Depends(verify_firebase_token)):
_verify_admin(user)
db = get_supabase()
def count(status: str) -> int:
res = db.table("news_items").select("id", count="exact").eq("status", status).execute()
return res.count or 0
sources = db.table("news_sources").select("active").execute().data
last_poll = (
db.table("news_polls")
.select("id, started_at, finished_at, error")
.order("started_at", desc=True)
.limit(1)
.execute()
.data
)
return {
"published": count("classified"),
"needs_review": count("needs_review"),
"rejected": count("rejected"),
"sources_active": sum(1 for s in sources if s["active"]),
"sources_total": len(sources),
"last_poll": last_poll[0] if last_poll else None,
}
@router.get("/admin/sources")
def list_sources(user: dict = Depends(verify_firebase_token)):
_verify_admin(user)
db = get_supabase()
sources = db.table("news_sources").select("*").order("name").execute().data
return {"sources": sources}
class SourceUpdate(BaseModel):
active: bool
@router.patch("/admin/sources/{source_id}")
def update_source(source_id: str, body: SourceUpdate, user: dict = Depends(verify_firebase_token)):
admin_uid = _verify_admin(user)
db = get_supabase()
updated = db.table("news_sources").update({"active": body.active}).eq("id", source_id).execute().data
name = updated[0]["name"] if updated else source_id
log_action(admin_uid, "source", "enabled" if body.active else "disabled", name, target=source_id)
return {"success": True}
@router.get("/admin/polls")
def list_polls(user: dict = Depends(verify_firebase_token)):
_verify_admin(user)
db = get_supabase()
polls = (
db.table("news_polls")
.select("id, triggered_by, since_days, started_at, finished_at, items_scraped, items_deduped_out, items_classified, items_flagged, items_rejected, current_source, error")
.order("started_at", desc=True)
.limit(10)
.execute()
.data
)
return {"polls": polls}
@router.get("/items")
def list_items(business_type: str | None = None, user: dict = Depends(verify_firebase_token)):
db = get_supabase()
now_iso = datetime.now(timezone.utc).isoformat()
query = (
db.table("news_items")
.select("id, title, url, impact, category, business_types, location, deadline, expires_at, published_at")
.eq("status", "classified")
.gt("expires_at", now_iso)
.order("published_at", desc=True)
.limit(100)
)
if business_type:
query = query.contains("business_types", [business_type])
return {"items": query.execute().data}
@router.get("/admin/items")
def admin_list_items(
status: str | None = None,
limit: int = 50,
offset: int = 0,
user: dict = Depends(verify_firebase_token),
):
_verify_admin(user)
db = get_supabase()
query = db.table("news_items").select(
"id, title, url, impact, category, business_types, location, confidence, status, scraped_at, published_at, expires_at",
count="exact",
).order("scraped_at", desc=True).range(offset, offset + limit - 1)
if status:
query = query.eq("status", status)
res = query.execute()
return {"items": res.data, "total": res.count or 0}
@router.get("/admin/review")
def list_review_queue(user: dict = Depends(verify_firebase_token)):
_verify_admin(user)
db = get_supabase()
items = (
db.table("news_items")
.select("id, title, url, raw_text, impact, category, business_types, location, review_reason, scraped_at")
.eq("status", "needs_review")
.order("scraped_at", desc=True)
.limit(100)
.execute()
.data
)
return {"items": items}
class ReviewFix(BaseModel):
status: str = Field(pattern="^(classified|rejected)$")
impact: str | None = None
category: str | None = None
business_types: list[str] | None = None
location: str | None = None
@router.patch("/admin/review/{item_id}")
def fix_review_item(item_id: str, body: ReviewFix, user: dict = Depends(verify_firebase_token)):
admin_uid = _verify_admin(user)
db = get_supabase()
if body.status == "rejected":
updated = db.table("news_items").update({"status": "rejected"}).eq("id", item_id).execute().data
title = updated[0]["title"] if updated else item_id
log_action(admin_uid, "review", "discarded", title, target=item_id)
return {"success": True, "status": "rejected"}
# Completing by hand still has to satisfy the same bar automated classification does.
if body.impact and body.impact not in IMPACTS:
raise HTTPException(status_code=400, detail=f"impact must be one of {IMPACTS}")
if body.category and body.category not in CATEGORIES:
raise HTTPException(status_code=400, detail=f"category must be one of {CATEGORIES}")
business_types = valid_subset(body.business_types or [])
if not (body.category and body.location and business_types):
raise HTTPException(status_code=400, detail="category, location and at least one valid business_type are required to mark this classified.")
updated = db.table("news_items").update({
"status": "classified",
"impact": body.impact,
"category": body.category,
"business_types": business_types,
"location": body.location,
"review_reason": None,
"classified_at": datetime.now(timezone.utc).isoformat(),
}).eq("id", item_id).execute().data
title = updated[0]["title"] if updated else item_id
log_action(admin_uid, "review", "published", title, target=item_id)
if updated:
record_news_item(updated[0])
return {"success": True, "status": "classified"}