magpie/app/api/endpoints/campaigns.py
Alan Weinstock e9b4cdd88e feat: link_url variants, team accounts, session layout, menagerie route (#18 #19)
#19 — link_url on campaign variants (migration 019)
- ADD COLUMN link_url TEXT on campaign_variants
- create_variant, upsert_variant, update_variant all carry link_url
- RedditClient.post() supports kind=link when link_url set + body empty
- RedditPostStrategy passes link_url from extra dict
- poster.py merges link_url from variant into extra (same as slug/tags)
- API VariantCreate/VariantUpdate schemas include link_url
- CampaignDetail: link_url field in Add Variant form with copy button;
  link_url shown in variant list with clickable link + copy button
- Variant button disabled if neither body nor link_url is set

#18 — Multi-user team accounts (migrations 020-022)
- 020: team_accounts table (display_name, platform, username, session_file)
- 021: opportunities.assigned_to + post_as FK → team_accounts
- 022: posts.posted_by_account_id FK → team_accounts
- Store: list/get/get_by_username/create_team_account, assign_opportunity
- API: GET/POST /api/v1/team; POST /api/v1/team/{id}/assign
- config.py: sessions_dir added; reddit_session_file now points to
  sessions/alan_reddit.json (backward compat path kept)
- scripts/migrate_sessions.py: one-shot move session.json →
  sessions/alan_reddit.json + creates placeholder files for future accounts
- manage.sh: build (VITE_BASE_URL=/magpie/ npm build), serve (static),
  migrate-sessions subcommands added; login updated to new session path
- Caddy: @magpie_no_session gate + handle /magpie/api* and /magpie*
  blocks added to menagerie.circuitforge.tech site block
2026-05-27 15:31:58 -07:00

186 lines
5.8 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
link_url: 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
link_url: 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")