#14 — Structured logging - app/core/logging_config.py: configure_logging() sets stdout handler with timestamped format; called at import time in main.py - Global FastAPI exception_handler logs 500s with full traceback - opportunities.py: logger added; create/approve/mark-posted/dismiss each emit an info line so failures are traceable #15 — Frontend error handling - frontend/src/composables/useToast.ts: shared toast composable (error/success/info, auto-dismiss, module-level singleton) - frontend/src/components/ToastList.vue: fixed-position overlay, theme-aware, accessible (role=alert, aria-live=polite) - OpportunitiesView: all 6 async actions have catch + toast.error() - CampaignDetail: onMounted + all 6 mutation functions wrapped #16 — Aggregate stats - app/api/endpoints/stats.py: GET /api/v1/stats — single DB pass via GROUP BY; returns posts totals, 7-day count, top communities, platform breakdown, and opportunity queue counts - frontend/src/components/StatsBar.vue: slim header bar above router-view; chips for posts ok/failed/week, queue pending/approved/ posted, top community; hides gracefully on API error
177 lines
5.8 KiB
Python
177 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from app.core.config import get_settings
|
|
from app.db.store import Store
|
|
|
|
router = APIRouter(prefix="/opportunities", tags=["opportunities"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
VALID_STATUSES = {"pending_review", "approved", "posted", "manual_posted", "dismissed"}
|
|
|
|
|
|
def _get_store() -> Store:
|
|
return Store(get_settings().db_path)
|
|
|
|
|
|
def _in_thread(fn):
|
|
store = _get_store()
|
|
try:
|
|
return fn(store)
|
|
finally:
|
|
store.close()
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Schemas
|
|
# ------------------------------------------------------------------ #
|
|
|
|
class OpportunityCreate(BaseModel):
|
|
platform: str = "reddit"
|
|
community: str
|
|
thread_url: str
|
|
thread_title: str | None = None
|
|
thread_body: str | None = None
|
|
signal_reason: str | None = None
|
|
product: str | None = None
|
|
draft_title: str | None = None
|
|
draft_body: str = ""
|
|
post_type: Literal["reply_to_thread", "new_post"] = "reply_to_thread"
|
|
campaign_id: int | None = None
|
|
|
|
|
|
class OpportunityUpdate(BaseModel):
|
|
draft_title: str | None = None
|
|
draft_body: str | None = None
|
|
signal_reason: str | None = None
|
|
product: str | None = None
|
|
status: str | None = None
|
|
campaign_id: int | None = None
|
|
|
|
|
|
class DismissBody(BaseModel):
|
|
note: str | None = None
|
|
|
|
|
|
class MarkPostedBody(BaseModel):
|
|
url: str | None = None
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Routes
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@router.get("")
|
|
async def list_opportunities(status: str | None = None):
|
|
if status and status not in VALID_STATUSES:
|
|
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_STATUSES)}")
|
|
return await asyncio.to_thread(_in_thread, lambda s: s.list_opportunities(status))
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
async def create_opportunity(body: OpportunityCreate):
|
|
logger.info("Creating opportunity: platform=%s community=%s", body.platform, body.community)
|
|
result = await asyncio.to_thread(
|
|
_in_thread,
|
|
lambda s: s.create_opportunity(**body.model_dump()),
|
|
)
|
|
logger.info("Opportunity created: id=%s", result.get("id"))
|
|
return result
|
|
|
|
|
|
@router.get("/{opportunity_id}")
|
|
async def get_opportunity(opportunity_id: int):
|
|
result = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
|
|
if result is None:
|
|
raise HTTPException(404, "Opportunity not found")
|
|
return result
|
|
|
|
|
|
@router.patch("/{opportunity_id}")
|
|
async def update_opportunity(opportunity_id: int, body: OpportunityUpdate):
|
|
updates = {k: v for k, v in body.model_dump().items() if v is not None}
|
|
if "status" in updates and updates["status"] not in VALID_STATUSES:
|
|
raise HTTPException(400, f"Invalid status. Valid: {sorted(VALID_STATUSES)}")
|
|
result = await asyncio.to_thread(
|
|
_in_thread, lambda s: s.update_opportunity(opportunity_id, **updates)
|
|
)
|
|
if result is None:
|
|
raise HTTPException(404, "Opportunity not found")
|
|
return result
|
|
|
|
|
|
@router.post("/{opportunity_id}/approve")
|
|
async def approve_opportunity(opportunity_id: int):
|
|
"""Mark as approved. For Reddit opportunities, returns auto-post instructions.
|
|
For other platforms, returns a manual handoff payload."""
|
|
logger.info("Approving opportunity id=%s", opportunity_id)
|
|
opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
|
|
if opp is None:
|
|
raise HTTPException(404, "Opportunity not found")
|
|
|
|
updated = await asyncio.to_thread(
|
|
_in_thread, lambda s: s.approve_opportunity(opportunity_id)
|
|
)
|
|
|
|
if opp["platform"] == "reddit":
|
|
return {
|
|
"type": "auto_post_ready",
|
|
"opportunity": updated,
|
|
"instructions": "Use trigger_sub_post with the linked campaign, or fire manually via post.py.",
|
|
}
|
|
|
|
return {
|
|
"type": "manual_handoff",
|
|
"opportunity": updated,
|
|
"draft_body": opp["draft_body"],
|
|
"thread_url": opp["thread_url"],
|
|
"instructions": (
|
|
f"Copy the draft and reply to this thread manually ({opp['platform']}). "
|
|
"Mark as manual_posted when done."
|
|
),
|
|
}
|
|
|
|
|
|
@router.post("/{opportunity_id}/mark-posted")
|
|
async def mark_posted(opportunity_id: int, body: MarkPostedBody = MarkPostedBody(), manual: bool = False):
|
|
"""Record that a post was successfully made (auto or manual).
|
|
When manual=True, also writes a row to the posts table for history tracking."""
|
|
logger.info("mark-posted: id=%s manual=%s url=%r", opportunity_id, manual, body.url)
|
|
opp = await asyncio.to_thread(_in_thread, lambda s: s.get_opportunity(opportunity_id))
|
|
if opp is None:
|
|
raise HTTPException(404, "Opportunity not found")
|
|
|
|
status = "manual_posted" if manual else "posted"
|
|
result = await asyncio.to_thread(
|
|
_in_thread, lambda s: s.update_opportunity(opportunity_id, status=status)
|
|
)
|
|
|
|
if manual:
|
|
await asyncio.to_thread(
|
|
_in_thread,
|
|
lambda s: s.log_manual_post(
|
|
opportunity_id=opportunity_id,
|
|
platform=opp["platform"],
|
|
target=opp["community"],
|
|
url=body.url,
|
|
),
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/{opportunity_id}/dismiss")
|
|
async def dismiss_opportunity(opportunity_id: int, body: DismissBody):
|
|
logger.info("Dismissing opportunity id=%s note=%r", opportunity_id, body.note)
|
|
result = await asyncio.to_thread(
|
|
_in_thread, lambda s: s.dismiss_opportunity(opportunity_id, body.note)
|
|
)
|
|
if result is None:
|
|
raise HTTPException(404, "Opportunity not found")
|
|
return result
|