magpie/app/api/endpoints/campaigns.py
Alan Weinstock bd58f9f54e feat: scaffold Magpie — campaign scheduler + social posting platform
FastAPI backend (SQLite + APScheduler), Vue 3 frontend, MCP server for
Claude integration, and Docker Compose stack. Includes campaign data model
(campaigns → variants → subs), post history, sub rules, and Playwright-based
Reddit posting layer migrated from claude-bridge/reddit-poster.

Also seeds legacy campaigns (6) and sub rules (14) from reddit-poster history.

Closes #1 (scaffold), resolves migration from claude-bridge.
2026-04-21 16:51:33 -07:00

184 lines
5.7 KiB
Python

from __future__ import annotations
import asyncio
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.core.config import get_settings
from app.db.store import Store
from app.services.poster import run_campaign
from app.services.scheduler import remove_campaign as scheduler_remove, sync_campaign
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
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 CampaignCreate(BaseModel):
name: str
product: str
platform: str = "reddit"
cron_schedule: str | None = None
notes: str | None = None
class CampaignUpdate(BaseModel):
name: str | None = None
product: str | None = None
cron_schedule: str | None = None
active: bool | None = None
notes: str | None = None
class VariantCreate(BaseModel):
sub_pattern: str = "*"
title: str
body: str
flair: str | None = None
notes: str | None = None
class VariantUpdate(BaseModel):
sub_pattern: str | None = None
title: str | None = None
body: str | None = None
flair: str | None = None
notes: str | None = None
class SubEntry(BaseModel):
sub: str
sort_order: int = 0
# ------------------------------------------------------------------ #
# Campaign CRUD
# ------------------------------------------------------------------ #
@router.get("")
async def list_campaigns(active_only: bool = False):
return await asyncio.to_thread(_in_thread, lambda s: s.list_campaigns(active_only))
@router.post("", status_code=201)
async def create_campaign(body: CampaignCreate):
campaign = await asyncio.to_thread(
_in_thread,
lambda s: s.create_campaign(**body.model_dump()),
)
sync_campaign(campaign)
return campaign
@router.get("/{campaign_id}")
async def get_campaign(campaign_id: int):
result = await asyncio.to_thread(_in_thread, lambda s: s.get_campaign(campaign_id))
if result is None:
raise HTTPException(404, "Campaign not found")
return result
@router.patch("/{campaign_id}")
async def update_campaign(campaign_id: int, body: CampaignUpdate):
updates = {k: v for k, v in body.model_dump().items() if v is not None}
if "active" in updates:
updates["active"] = 1 if updates["active"] else 0
result = await asyncio.to_thread(_in_thread, lambda s: s.update_campaign(campaign_id, **updates))
if result is None:
raise HTTPException(404, "Campaign not found")
sync_campaign(result)
return result
@router.delete("/{campaign_id}", status_code=204)
async def delete_campaign(campaign_id: int):
ok = await asyncio.to_thread(_in_thread, lambda s: s.delete_campaign(campaign_id))
if not ok:
raise HTTPException(404, "Campaign not found")
scheduler_remove(campaign_id)
# ------------------------------------------------------------------ #
# Manual trigger
# ------------------------------------------------------------------ #
@router.post("/{campaign_id}/trigger")
async def trigger_campaign(campaign_id: int):
"""Manually fire a campaign to all its configured subs."""
campaign = await asyncio.to_thread(_in_thread, lambda s: s.get_campaign(campaign_id))
if campaign is None:
raise HTTPException(404, "Campaign not found")
results = await run_campaign(campaign_id, triggered_by="manual")
return {"campaign_id": campaign_id, "results": results}
# ------------------------------------------------------------------ #
# Variants
# ------------------------------------------------------------------ #
@router.get("/{campaign_id}/variants")
async def list_variants(campaign_id: int):
return await asyncio.to_thread(_in_thread, lambda s: s.list_variants(campaign_id))
@router.post("/{campaign_id}/variants", status_code=201)
async def create_variant(campaign_id: int, body: VariantCreate):
return await asyncio.to_thread(
_in_thread,
lambda s: s.create_variant(campaign_id=campaign_id, **body.model_dump()),
)
@router.patch("/{campaign_id}/variants/{variant_id}")
async def update_variant(campaign_id: int, variant_id: int, body: VariantUpdate):
updates = {k: v for k, v in body.model_dump().items() if v is not None}
result = await asyncio.to_thread(_in_thread, lambda s: s.update_variant(variant_id, **updates))
if result is None:
raise HTTPException(404, "Variant not found")
return result
@router.delete("/{campaign_id}/variants/{variant_id}", status_code=204)
async def delete_variant(campaign_id: int, variant_id: int):
ok = await asyncio.to_thread(_in_thread, lambda s: s.delete_variant(variant_id))
if not ok:
raise HTTPException(404, "Variant not found")
# ------------------------------------------------------------------ #
# Subs
# ------------------------------------------------------------------ #
@router.get("/{campaign_id}/subs")
async def list_campaign_subs(campaign_id: int):
return await asyncio.to_thread(_in_thread, lambda s: s.list_campaign_subs(campaign_id))
@router.post("/{campaign_id}/subs", status_code=201)
async def add_campaign_sub(campaign_id: int, body: SubEntry):
return await asyncio.to_thread(
_in_thread,
lambda s: s.add_campaign_sub(campaign_id, body.sub, body.sort_order),
)
@router.delete("/{campaign_id}/subs/{sub}", status_code=204)
async def remove_campaign_sub(campaign_id: int, sub: str):
ok = await asyncio.to_thread(_in_thread, lambda s: s.remove_campaign_sub(campaign_id, sub))
if not ok:
raise HTTPException(404, "Sub not found in campaign")