- app/services/directus.py: Directus CMS client using docker run curlimages/curl on website_cf-internal network; supports static admin token with fresh JWT login fallback; get/publish/update blog_posts - app/api/endpoints/blog.py: POST /api/v1/blog (publish), GET /slug, PATCH /id endpoints - app/api/routes.py: register blog router - app/core/config.py: add directus_url/token/email/password/network settings - mcp/server.js: add publish_blog_post and get_blog_post MCP tools Key gotcha: Directus filter[field][_eq] brackets must be percent-encoded when passed as a curl CLI URL arg — raw brackets cause curl to exit non-zero with empty stderr.
148 lines
4.8 KiB
Python
148 lines
4.8 KiB
Python
"""
|
|
Directus blog post publisher for the CircuitForge website CMS.
|
|
|
|
Directus runs in Docker on the website_cf-internal network and is not
|
|
directly reachable from host processes. We shell out to a one-shot
|
|
curlimages/curl container joined to that network.
|
|
|
|
Collection: blog_posts
|
|
Fields: id, title, slug, body, published_at, tags, author, seo_description
|
|
|
|
Environment variables (via Magpie .env):
|
|
DIRECTUS_URL Base URL inside the cf-internal network
|
|
(default: http://172.31.0.4:8055)
|
|
DIRECTUS_ADMIN_TOKEN Static admin token
|
|
DIRECTUS_ADMIN_EMAIL Admin email (for fresh JWT fallback)
|
|
DIRECTUS_ADMIN_PASSWORD
|
|
DIRECTUS_NETWORK Docker network name
|
|
(default: website_cf-internal)
|
|
|
|
IP gotcha: 172.31.0.4 is the current cf-directus address on website_cf-internal.
|
|
If calls start returning connection errors run:
|
|
docker inspect cf-directus --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
|
|
and update DIRECTUS_URL in Magpie's .env.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from app.core.config import get_settings
|
|
|
|
_CURL_IMAGE = "curlimages/curl:latest"
|
|
|
|
|
|
def _curl(method: str, path: str, token: str, body: dict[str, Any] | None = None) -> dict:
|
|
"""Run a curl request inside a container on the cf-internal network."""
|
|
cfg = get_settings()
|
|
url = f"{cfg.directus_url}{path}"
|
|
cmd = [
|
|
"docker", "run", "--rm",
|
|
"--network", cfg.directus_network,
|
|
_CURL_IMAGE,
|
|
"-sf", "-X", method, url,
|
|
"-H", f"Authorization: Bearer {token}",
|
|
"-H", "Content-Type: application/json",
|
|
]
|
|
if body is not None:
|
|
cmd += ["--data", json.dumps(body)]
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"Directus {method} {path} failed: {result.stderr.strip()}")
|
|
if not result.stdout.strip():
|
|
return {}
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def _get_token() -> str:
|
|
"""Return a usable token: static admin token, or fresh JWT via login."""
|
|
cfg = get_settings()
|
|
if cfg.directus_admin_token:
|
|
return cfg.directus_admin_token
|
|
if not (cfg.directus_admin_email and cfg.directus_admin_password):
|
|
raise RuntimeError(
|
|
"No Directus credentials configured. "
|
|
"Set DIRECTUS_ADMIN_TOKEN or DIRECTUS_ADMIN_EMAIL + DIRECTUS_ADMIN_PASSWORD."
|
|
)
|
|
resp = _curl("POST", "/auth/login", token="", body={
|
|
"email": cfg.directus_admin_email,
|
|
"password": cfg.directus_admin_password,
|
|
})
|
|
access_token = resp.get("data", {}).get("access_token")
|
|
if not access_token:
|
|
raise RuntimeError(f"Directus login failed: {resp}")
|
|
return access_token
|
|
|
|
|
|
def slugify(text: str) -> str:
|
|
slug = text.lower().strip()
|
|
slug = re.sub(r"[^\w\s-]", "", slug)
|
|
slug = re.sub(r"[\s_]+", "-", slug)
|
|
slug = re.sub(r"-+", "-", slug).strip("-")
|
|
return slug
|
|
|
|
|
|
def publish_blog_post(
|
|
title: str,
|
|
body: str,
|
|
slug: str | None = None,
|
|
tags: list[str] | None = None,
|
|
author: str | None = None,
|
|
seo_description: str | None = None,
|
|
published_at: str | None = None,
|
|
) -> dict:
|
|
"""
|
|
Create and publish a blog post in Directus.
|
|
|
|
Returns the created item dict (id, slug, title, ...).
|
|
|
|
published_at defaults to now (UTC ISO 8601). Pass None or omit to publish
|
|
immediately. Pass a future timestamp to schedule.
|
|
"""
|
|
token = _get_token()
|
|
|
|
_slug = slug or slugify(title)
|
|
_published_at = published_at or datetime.now(timezone.utc).isoformat()
|
|
|
|
payload: dict[str, Any] = {
|
|
"title": title,
|
|
"slug": _slug,
|
|
"body": body,
|
|
"published_at": _published_at,
|
|
}
|
|
if tags:
|
|
payload["tags"] = tags
|
|
if author:
|
|
payload["author"] = author
|
|
if seo_description:
|
|
payload["seo_description"] = seo_description
|
|
|
|
resp = _curl("POST", "/items/blog_posts", token=token, body=payload)
|
|
item = resp.get("data", resp)
|
|
return item
|
|
|
|
|
|
def get_blog_post(slug: str) -> dict | None:
|
|
"""Fetch a blog post by slug. Returns None if not found."""
|
|
from urllib.parse import quote
|
|
token = _get_token()
|
|
# Directus filter syntax uses brackets which must be percent-encoded for curl CLI
|
|
filter_param = f"filter%5Bslug%5D%5B_eq%5D={quote(slug, safe='')}"
|
|
resp = _curl(
|
|
"GET",
|
|
f"/items/blog_posts?{filter_param}&limit=1",
|
|
token=token,
|
|
)
|
|
items = resp.get("data", [])
|
|
return items[0] if items else None
|
|
|
|
|
|
def update_blog_post(post_id: int, fields: dict[str, Any]) -> dict:
|
|
"""Patch an existing blog post by ID."""
|
|
token = _get_token()
|
|
resp = _curl("PATCH", f"/items/blog_posts/{post_id}", token=token, body=fields)
|
|
return resp.get("data", resp)
|