diff --git a/app/api/endpoints/campaigns.py b/app/api/endpoints/campaigns.py index 52ea8e9..138dd8e 100644 --- a/app/api/endpoints/campaigns.py +++ b/app/api/endpoints/campaigns.py @@ -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): diff --git a/app/api/endpoints/team.py b/app/api/endpoints/team.py new file mode 100644 index 0000000..c9e3443 --- /dev/null +++ b/app/api/endpoints/team.py @@ -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 diff --git a/app/api/routes.py b/app/api/routes.py index 08373ad..c9fc1cb 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -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") diff --git a/app/core/config.py b/app/core/config.py index 8bfe806..8e59f97 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 diff --git a/app/db/migrations/019_variant_link_url.sql b/app/db/migrations/019_variant_link_url.sql new file mode 100644 index 0000000..079f62f --- /dev/null +++ b/app/db/migrations/019_variant_link_url.sql @@ -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; diff --git a/app/db/migrations/020_team_accounts.sql b/app/db/migrations/020_team_accounts.sql new file mode 100644 index 0000000..cbd2ae2 --- /dev/null +++ b/app/db/migrations/020_team_accounts.sql @@ -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) +); diff --git a/app/db/migrations/021_opportunities_assignment.sql b/app/db/migrations/021_opportunities_assignment.sql new file mode 100644 index 0000000..6b8abed --- /dev/null +++ b/app/db/migrations/021_opportunities_assignment.sql @@ -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); diff --git a/app/db/migrations/022_posts_account_tracking.sql b/app/db/migrations/022_posts_account_tracking.sql new file mode 100644 index 0000000..ca0c01b --- /dev/null +++ b/app/db/migrations/022_posts_account_tracking.sql @@ -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); diff --git a/app/db/store.py b/app/db/store.py index 7228dd0..1708009 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -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) diff --git a/app/services/platforms/reddit_post.py b/app/services/platforms/reddit_post.py index 10393af..36598b0 100644 --- a/app/services/platforms/reddit_post.py +++ b/app/services/platforms/reddit_post.py @@ -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) diff --git a/app/services/poster.py b/app/services/poster.py index 8617d2c..4a729b6 100644 --- a/app/services/poster.py +++ b/app/services/poster.py @@ -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]) diff --git a/app/services/reddit/client.py b/app/services/reddit/client.py index 5ebff9a..a41a786 100644 --- a/app/services/reddit/client.py +++ b/app/services/reddit/client.py @@ -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, diff --git a/frontend/src/components/CampaignDetail.vue b/frontend/src/components/CampaignDetail.vue index f2dd6a4..a2a3f37 100644 --- a/frontend/src/components/CampaignDetail.vue +++ b/frontend/src/components/CampaignDetail.vue @@ -31,6 +31,10 @@
{{ v.title }}
+
+ {{ v.link_url }} + +
{{ v.body }}
@@ -101,7 +105,14 @@
- + +
+ + +
+
+
+