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
This commit is contained in:
Alan Weinstock 2026-05-27 15:31:58 -07:00
parent a863960266
commit e9b4cdd88e
17 changed files with 360 additions and 38 deletions

View file

@ -51,6 +51,7 @@ class VariantCreate(BaseModel):
body: str
flair: str | None = None
notes: str | None = None
link_url: str | None = None
class VariantUpdate(BaseModel):
@ -59,6 +60,7 @@ class VariantUpdate(BaseModel):
body: str | None = None
flair: str | None = None
notes: str | None = None
link_url: str | None = None
class SubEntry(BaseModel):

89
app/api/endpoints/team.py Normal file
View file

@ -0,0 +1,89 @@
"""
Team accounts list and manage posting identities across platforms.
Read operations are available to all; create/update reserved for admin use.
"""
from __future__ import annotations
import asyncio
import logging
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="/team", tags=["team"])
logger = logging.getLogger(__name__)
def _get_store() -> Store:
return Store(get_settings().db_path)
def _in_thread(fn):
store = _get_store()
try:
return fn(store)
finally:
store.close()
class TeamAccountCreate(BaseModel):
display_name: str
platform: str
username: str
account_type: str = "personal"
session_file: str | None = None
notes: str | None = None
# ------------------------------------------------------------------ #
# Routes
# ------------------------------------------------------------------ #
@router.get("")
async def list_team_accounts(platform: str | None = None, active_only: bool = True):
"""List all registered team accounts. Filter by platform if provided."""
return await asyncio.to_thread(
_in_thread,
lambda s: s.list_team_accounts(platform=platform, active_only=active_only),
)
@router.get("/{account_id}")
async def get_team_account(account_id: int):
result = await asyncio.to_thread(_in_thread, lambda s: s.get_team_account(account_id))
if result is None:
raise HTTPException(404, "Team account not found")
return result
@router.post("", status_code=201)
async def create_team_account(body: TeamAccountCreate):
logger.info(
"Creating team account: %s / %s (%s)", body.display_name, body.platform, body.account_type
)
return await asyncio.to_thread(
_in_thread,
lambda s: s.create_team_account(**body.model_dump()),
)
@router.post("/{opportunity_id}/assign")
async def assign_opportunity(
opportunity_id: int,
assigned_to: int | None = None,
post_as: int | None = None,
):
"""Assign an opportunity to a team member and/or set the posting account."""
result = await asyncio.to_thread(
_in_thread,
lambda s: s.assign_opportunity(opportunity_id, assigned_to, post_as),
)
if result is None:
raise HTTPException(404, "Opportunity not found")
logger.info(
"Opportunity %s assigned_to=%s post_as=%s", opportunity_id, assigned_to, post_as
)
return result

View file

@ -1,6 +1,6 @@
from fastapi import FastAPI
from app.api.endpoints import blog, campaigns, opportunities, posts, reddit, scheduler, signals, stats, subs
from app.api.endpoints import blog, campaigns, opportunities, posts, reddit, scheduler, signals, stats, subs, team
def register_routes(app: FastAPI) -> None:
@ -13,3 +13,4 @@ def register_routes(app: FastAPI) -> None:
app.include_router(blog.router, prefix="/api/v1")
app.include_router(reddit.router, prefix="/api/v1")
app.include_router(stats.router, prefix="/api/v1")
app.include_router(team.router, prefix="/api/v1")

View file

@ -15,8 +15,12 @@ class Settings(BaseSettings):
# Database
db_path: str = str(Path.home() / ".local" / "share" / "magpie" / "magpie.db")
# Reddit session
reddit_session_file: str = str(Path.home() / ".local" / "share" / "magpie" / "session.json")
# Session files — multi-user layout
# sessions_dir holds per-account JSON files: alan_reddit.json, cf_reddit.json, etc.
# reddit_session_file kept for backward compat; still used by the campaign scheduler
# until all callers are migrated to look up session via team_accounts.
sessions_dir: str = str(Path.home() / ".local" / "share" / "magpie" / "sessions")
reddit_session_file: str = str(Path.home() / ".local" / "share" / "magpie" / "sessions" / "alan_reddit.json")
# Scheduler
scheduler_enabled: bool = True

View file

@ -0,0 +1,5 @@
-- Migration 019: Add link_url to campaign_variants
-- Supports link-style posts (Reddit link posts, Lemmy link posts) where the URL
-- appears as the post link rather than embedded in the body. Also useful for
-- one-click copying the canonical URL from the variant editor UI.
ALTER TABLE campaign_variants ADD COLUMN link_url TEXT;

View file

@ -0,0 +1,16 @@
-- Migration 020: Team accounts table
-- Tracks all posting identities across platforms (personal and official).
-- session_file is an absolute path to the session JSON; NULL for accounts
-- that post manually (Neon) or whose sessions haven't been established yet.
CREATE TABLE IF NOT EXISTS team_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
platform TEXT NOT NULL, -- reddit | lemmy | mastodon | bluesky
username TEXT NOT NULL, -- u/pyr0ball, @cf@floss.social, etc.
account_type TEXT NOT NULL DEFAULT 'personal', -- personal | official
session_file TEXT, -- absolute path; NULL = manual posting only
active INTEGER NOT NULL DEFAULT 1,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(platform, username)
);

View file

@ -0,0 +1,6 @@
-- Migration 021: Opportunity assignment + posting account
-- assigned_to: which team member is responsible for this opportunity.
-- post_as: which team account to use when auto-posting.
-- These are separate: Alan may be assigned to review but post as CF official.
ALTER TABLE opportunities ADD COLUMN assigned_to INTEGER REFERENCES team_accounts(id);
ALTER TABLE opportunities ADD COLUMN post_as INTEGER REFERENCES team_accounts(id);

View file

@ -0,0 +1,3 @@
-- Migration 022: Track which account made each post
-- NULL = posted by the default account (alan_reddit) before multi-user was added.
ALTER TABLE posts ADD COLUMN posted_by_account_id INTEGER REFERENCES team_accounts(id);

View file

@ -167,10 +167,11 @@ class Store:
def create_variant(self, campaign_id: int, title: str, body: str,
sub_pattern: str = "*", flair: str | None = None,
notes: str | None = None) -> dict:
notes: str | None = None, link_url: str | None = None) -> dict:
return self._insert_returning(
"INSERT INTO campaign_variants (campaign_id, sub_pattern, title, body, flair, notes) VALUES (?,?,?,?,?,?) RETURNING *",
(campaign_id, sub_pattern, title, body, flair, notes),
"INSERT INTO campaign_variants (campaign_id, sub_pattern, title, body, flair, notes, link_url)"
" VALUES (?,?,?,?,?,?,?) RETURNING *",
(campaign_id, sub_pattern, title, body, flair, notes, link_url),
)
def upsert_variant(
@ -183,6 +184,7 @@ class Store:
slug: str | None = None,
tags: str | None = None,
seo_description: str | None = None,
link_url: str | None = None,
) -> dict:
existing = self._fetchone(
"SELECT * FROM campaign_variants WHERE campaign_id = ? AND sub_pattern = ?",
@ -190,19 +192,22 @@ class Store:
)
if existing:
self.conn.execute(
"UPDATE campaign_variants SET title=?, body=?, flair=?, slug=?, tags=?, seo_description=? WHERE id=?",
(title, body, flair, slug, tags, seo_description, existing["id"]),
"UPDATE campaign_variants SET title=?, body=?, flair=?, slug=?, tags=?,"
" seo_description=?, link_url=? WHERE id=?",
(title, body, flair, slug, tags, seo_description, link_url, existing["id"]),
)
self.conn.commit()
return self._fetchone("SELECT * FROM campaign_variants WHERE id=?", (existing["id"],))
return self._insert_returning(
"INSERT INTO campaign_variants (campaign_id, sub_pattern, title, body, flair, slug, tags, seo_description)"
" VALUES (?,?,?,?,?,?,?,?) RETURNING *",
(campaign_id, sub_pattern, title, body, flair, slug, tags, seo_description),
"INSERT INTO campaign_variants"
" (campaign_id, sub_pattern, title, body, flair, slug, tags, seo_description, link_url)"
" VALUES (?,?,?,?,?,?,?,?,?) RETURNING *",
(campaign_id, sub_pattern, title, body, flair, slug, tags, seo_description, link_url),
)
def update_variant(self, variant_id: int, **fields) -> dict | None:
allowed = {"sub_pattern", "title", "body", "flair", "notes"}
allowed = {"sub_pattern", "title", "body", "flair", "notes",
"slug", "tags", "seo_description", "link_url"}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
return self.get_variant(variant_id)
@ -623,3 +628,63 @@ class Store:
"SELECT * FROM signal_scrape_state WHERE platform = ? ORDER BY sub",
(platform,),
)
# ------------------------------------------------------------------ #
# Team accounts (multi-user — migration 020)
# ------------------------------------------------------------------ #
def list_team_accounts(
self, platform: str | None = None, active_only: bool = True
) -> list[dict]:
if platform:
sql = "SELECT * FROM team_accounts WHERE platform = ?"
params: tuple = (platform,)
if active_only:
sql += " AND active = 1"
else:
sql = "SELECT * FROM team_accounts"
params = ()
if active_only:
sql += " WHERE active = 1"
sql += " ORDER BY display_name, platform"
return self._fetchall(sql, params)
def get_team_account(self, account_id: int) -> dict | None:
return self._fetchone(
"SELECT * FROM team_accounts WHERE id = ?", (account_id,)
)
def get_team_account_by_username(self, platform: str, username: str) -> dict | None:
return self._fetchone(
"SELECT * FROM team_accounts WHERE platform = ? AND username = ?",
(platform, username),
)
def create_team_account(
self,
display_name: str,
platform: str,
username: str,
account_type: str = "personal",
session_file: str | None = None,
notes: str | None = None,
) -> dict:
return self._insert_returning(
"INSERT INTO team_accounts"
" (display_name, platform, username, account_type, session_file, notes)"
" VALUES (?,?,?,?,?,?) RETURNING *",
(display_name, platform, username, account_type, session_file, notes),
)
def assign_opportunity(
self,
opp_id: int,
assigned_to: int | None,
post_as: int | None = None,
) -> dict | None:
self.conn.execute(
"UPDATE opportunities SET assigned_to = ?, post_as = ? WHERE id = ?",
(assigned_to, post_as, opp_id),
)
self.conn.commit()
return self.get_opportunity(opp_id)

View file

@ -21,5 +21,6 @@ class RedditPostStrategy(PostingStrategy):
) -> PostResult:
settings = get_settings()
client = RedditClient(session_file=settings.reddit_session_file)
url = client.post(sub=target, title=title, body=body, flair=flair)
link_url = (extra or {}).get("link_url") or None
url = client.post(sub=target, title=title, body=body, flair=flair, link_url=link_url)
return PostResult(url=url)

View file

@ -88,9 +88,9 @@ def _run_post(db_path: str, campaign_id: int, target: str,
)
post_id = post["id"]
# Build extra dict from sub_row; merge variant-level blog fields (blog_post strategy uses them)
# Build extra dict from sub_row; merge variant-level fields used by strategies
extra = dict(sub_row)
for field in ("slug", "tags", "seo_description"):
for field in ("slug", "tags", "seo_description", "link_url"):
if variant.get(field) is not None:
extra.setdefault(field, variant[field])

View file

@ -42,19 +42,46 @@ class RedditClient:
self._modhash = resp.json().get("data", {}).get("modhash", "")
return self._modhash
def post(self, sub: str, title: str, body: str, flair: str | None = None) -> str:
"""Submit a text post via Reddit legacy API (httpx). Returns the permalink."""
data = {
"api_type": "json",
"kind": "self",
"sr": sub,
"title": title,
"text": body,
"uh": self.modhash,
"sendreplies": "true",
"nsfw": "false",
"spoiler": "false",
}
def post(
self,
sub: str,
title: str,
body: str,
flair: str | None = None,
link_url: str | None = None,
) -> str:
"""Submit a post via Reddit legacy API (httpx). Returns the permalink.
If link_url is provided and body is empty, submits as a link post (kind=link).
If both link_url and body are provided, submits as a text post with the URL
embedded Reddit link posts don't support body text.
"""
if link_url and not body:
kind = "link"
data: dict = {
"api_type": "json",
"kind": kind,
"sr": sub,
"title": title,
"url": link_url,
"uh": self.modhash,
"sendreplies": "true",
"nsfw": "false",
"spoiler": "false",
}
else:
kind = "self"
data = {
"api_type": "json",
"kind": kind,
"sr": sub,
"title": title,
"text": body,
"uh": self.modhash,
"sendreplies": "true",
"nsfw": "false",
"spoiler": "false",
}
resp = httpx.post(
"https://www.reddit.com/api/submit",
cookies=self.cookies,

View file

@ -31,6 +31,10 @@
<button class="btn btn-ghost btn-sm" style="margin-left: auto;" @click="deleteVariant(v.id)"></button>
</div>
<div style="font-weight: 500; font-size: 13px; margin-bottom: 2px;">{{ v.title }}</div>
<div v-if="v.link_url" style="font-size: 11px; margin-bottom: 2px; display: flex; align-items: center; gap: 4px;">
<a :href="v.link_url" target="_blank" rel="noopener" style="color: var(--color-accent); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 340px;">{{ v.link_url }}</a>
<button class="btn btn-ghost" style="padding: 0 4px; font-size: 11px;" @click="copyText(v.link_url!)" title="Copy URL">📋</button>
</div>
<div style="color: var(--color-text-muted); font-size: 12px; white-space: pre-wrap; max-height: 60px; overflow: hidden;">{{ v.body }}</div>
</div>
</div>
@ -101,7 +105,14 @@
<input class="form-input" v-model="variantForm.title" placeholder="Post title..." />
</div>
<div class="form-group">
<label class="form-label">Body</label>
<label class="form-label">Link URL <span style="color: var(--color-text-muted)">(optional use for link posts; if set with no body, posts as link-type)</span></label>
<div style="display: flex; gap: 6px;">
<input class="form-input" v-model="variantForm.link_url" placeholder="https://git.opensourcesolarpunk.com/..." style="flex: 1;" />
<button v-if="variantForm.link_url" class="btn btn-ghost" style="flex-shrink: 0;" @click="copyText(variantForm.link_url)" title="Copy URL">📋</button>
</div>
</div>
<div class="form-group">
<label class="form-label">Body <span style="color: var(--color-text-muted)">(leave empty to post as link-type using the URL above)</span></label>
<textarea class="form-textarea" v-model="variantForm.body" rows="8" placeholder="Post body (markdown)..." style="min-height: 200px;" />
</div>
<div class="form-group">
@ -110,7 +121,7 @@
</div>
<div style="display: flex; gap: var(--spacing-sm); justify-content: flex-end;">
<button class="btn btn-ghost" @click="showAddVariant = false">Cancel</button>
<button class="btn btn-primary" @click="addVariant" :disabled="!variantForm.title || !variantForm.body">Add Variant</button>
<button class="btn btn-primary" @click="addVariant" :disabled="!variantForm.title || (!variantForm.body && !variantForm.link_url)">Add Variant</button>
</div>
</div>
</div>
@ -204,7 +215,7 @@ const showAddSub = ref(false)
const copyModal = reactive({ sub: '', title: '', body: '', url: '', notes: '' })
const copied = ref('')
const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '', notes: '', link_url: '' })
const subForm = reactive({ sub: '' })
onMounted(async () => {
@ -258,10 +269,11 @@ async function addVariant() {
body: variantForm.body,
flair: variantForm.flair || null,
notes: variantForm.notes || null,
link_url: variantForm.link_url || null,
})
variants.value = [...variants.value, v]
showAddVariant.value = false
Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '', link_url: '' })
} catch (e: unknown) {
toast.error(`Failed to add variant: ${e instanceof Error ? e.message : 'Unknown error'}`)
}
@ -322,6 +334,14 @@ async function copy(text: string, which: string) {
setTimeout(() => { copied.value = '' }, 2000)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
} catch {
toast.error('Could not copy to clipboard')
}
}
function formatDate(iso: string) {
const d = new Date(iso + 'Z')
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })

View file

@ -42,6 +42,7 @@ export interface Variant {
body: string
flair: string | null
notes: string | null
link_url: string | null
created_at: string
updated_at: string
}
@ -52,6 +53,7 @@ export interface VariantCreate {
body: string
flair?: string | null
notes?: string | null
link_url?: string | null
}
export interface CampaignSub {

View file

@ -244,9 +244,33 @@ case "$cmd" in
ok "Update complete"
;;
build)
# Build the frontend SPA for production serving on menagerie.circuitforge.tech/magpie
# VITE_BASE_URL must end with / so Vite generates correct asset paths.
info "Building frontend for /magpie/ path prefix..."
cd frontend && VITE_BASE_URL=/magpie/ npm run build && cd ..
ok "Build complete → frontend/dist/ (base=/magpie/)"
info "Serving: ./manage.sh serve"
;;
serve)
# Serve the pre-built frontend dist at port WEB_PORT using a simple static file server.
# In production, Caddy proxies menagerie.circuitforge.tech/magpie* → this port.
info "Serving pre-built frontend on :${WEB_PORT} ..."
conda run --no-capture-output -n "$CONDA_ENV" \
python -m http.server "$WEB_PORT" --directory frontend/dist >> "$LOG_WEB" 2>&1 &
echo $! > "$PID_WEB"
ok "Static server up → http://localhost:${WEB_PORT}"
;;
migrate-sessions)
info "Migrating session.json → sessions/ directory..."
conda run -n "$CONDA_ENV" python scripts/migrate_sessions.py
;;
login)
info "Refreshing Reddit session (opens browser via Xvfb)..."
REDDIT_SESSION_FILE="${DATA_DIR}/session.json" \
REDDIT_SESSION_FILE="${DATA_DIR}/sessions/alan_reddit.json" \
conda run --no-capture-output -n "$CONDA_ENV" \
xvfb-run --auto-servernum \
python -m app.services.reddit.post --login
@ -288,11 +312,14 @@ EOF
echo " Logs at: ${LOG_DIR}/"
echo ""
echo " Maintenance:"
echo " update git pull + pip/npm install + API restart"
echo " migrate Run DB migrations standalone"
echo " seed Seed campaigns from legacy scripts"
echo " login Refresh Reddit Playwright session"
echo " open Open dashboard in browser"
echo " update git pull + pip/npm install + API restart"
echo " migrate Run DB migrations standalone"
echo " migrate-sessions Move session.json → sessions/alan_reddit.json"
echo " seed Seed campaigns from legacy scripts"
echo " login Refresh Reddit Playwright session"
echo " build Build frontend for menagerie (/magpie/ base path)"
echo " serve Serve pre-built frontend dist on :${WEB_PORT}"
echo " open Open dashboard in browser"
echo ""
;;
esac

View file

@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
One-time migration: moves the legacy session.json to the new sessions/ directory.
Safe to run multiple times (idempotent).
Usage: conda run -n cf python scripts/migrate_sessions.py
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
DATA_DIR = Path.home() / ".local" / "share" / "magpie"
OLD_SESSION = DATA_DIR / "session.json"
SESSIONS_DIR = DATA_DIR / "sessions"
NEW_SESSION = SESSIONS_DIR / "alan_reddit.json"
def main() -> None:
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
print(f"Sessions directory: {SESSIONS_DIR}")
if NEW_SESSION.exists():
print(f" alan_reddit.json already exists — nothing to move")
elif OLD_SESSION.exists():
shutil.copy2(OLD_SESSION, NEW_SESSION)
print(f" Copied {OLD_SESSION}{NEW_SESSION}")
# Leave the original in place during rollout; remove manually once confirmed
print(f" NOTE: {OLD_SESSION} left in place. Remove manually after confirming.")
else:
print(f" No session.json found at {OLD_SESSION} — may need to run ./manage.sh login")
print("\nPlaceholder files created for future accounts (empty — fill in when ready):")
placeholders = [
"xander_reddit.json",
"neon_reddit.json",
"cf_reddit.json",
"cf_bluesky.json",
"cf_mastodon.json",
]
for name in placeholders:
path = SESSIONS_DIR / name
if not path.exists():
path.touch()
print(f" Created {path}")
else:
print(f" {name} already exists — skipping")
if __name__ == "__main__":
main()

View file

@ -27,6 +27,7 @@ def test_execute_delegates_to_reddit_client(tmp_path):
title="Test Title",
body="Test body",
flair="Showcase",
link_url=None,
)
assert isinstance(result, PostResult)
assert result.url == "https://reddit.com/r/test/comments/abc/title/"