diff --git a/.gitignore b/.gitignore
index 8a56a5d..2cc8bb9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,3 +57,7 @@ data/
data/db
data/logs
+# --- Local planning / prompt notes (not part of the product) ---
+CLAUDE_CODE_KICKOFF_PROMPT.md
+CONTENT_OS_BUILD_PLAN.md
+
diff --git a/HOW-TO-OPEN-PROJECT-TRACKER.md b/HOW-TO-OPEN-PROJECT-TRACKER.md
index 61806b0..8267558 100644
--- a/HOW-TO-OPEN-PROJECT-TRACKER.md
+++ b/HOW-TO-OPEN-PROJECT-TRACKER.md
@@ -50,3 +50,34 @@ To add it to your Start menu too:
- Choose **Show more options** (if you see it), then **Pin to Start**
Now you can open Project Tracker any time with one click. Done!
+
+## 8. (Optional) Let Claude help you with your content
+
+Project Tracker can connect to the **Claude Desktop app** so you can just *ask*
+Claude to plan, write, move, and schedule your content — instead of filling in
+forms yourself.
+
+**What you need:**
+
+- The **Claude Desktop app** installed on this computer and signed in.
+ (You can download it from Anthropic. Any Claude plan that supports connectors
+ works.) If you don't have Claude Desktop, the rest of Project Tracker still
+ works perfectly — this part is optional.
+
+**What it gives you (for free):**
+
+Once connected, you can open Claude Desktop and say things like:
+
+- *"What content do I have scheduled this week?"*
+- *"Write 5 Instagram captions about summer skincare and add them to my calendar
+ for next week."*
+- *"Move Tuesday's post to Friday."*
+- *"Approve the skincare post."*
+
+Claude does the thinking on **your Claude subscription**, so this doesn't use up
+any paid AI credits. (There's also a **✨ Generate** button *inside* Project
+Tracker that writes content for you — that one needs your own AI key, added in
+**AI Settings**. It's optional; Claude Desktop can do the same thing for free.)
+
+**How to connect:** see `mcp_server/README.md` for the one-time setup step, or ask
+whoever set up your Project Tracker to turn it on for you.
diff --git a/ai_assistant/__init__.py b/ai_assistant/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ai_assistant/agent.py b/ai_assistant/agent.py
new file mode 100644
index 0000000..5d736d3
--- /dev/null
+++ b/ai_assistant/agent.py
@@ -0,0 +1,77 @@
+# ai_assistant/agent.py
+"""The agent loop: send the conversation + tool schemas to the active provider,
+execute whatever tool call comes back, feed the result in, repeat until the model
+replies in plain text.
+
+Conversation messages use ai_client's normalized shape (plain dicts, so they
+round-trip through the Django session):
+ {"role": "user"|"assistant", "content": str, ["tool_calls": [...]]}
+ {"role": "tool", "tool_call_id": str, "name": str, "content": str}
+"""
+import json
+
+from ai_settings import ai_client
+
+from . import tools
+
+MAX_STEPS = 6 # safety cap on tool round-trips per user turn
+
+SYSTEM_PROMPT = (
+ "You are the content assistant inside a personal content calendar app. "
+ "You help plan, draft, schedule and review social content by calling the "
+ "provided tools. Today's date is available to you from context if given. "
+ "When the user asks to create, list, move, approve, or generate content, "
+ "use the tools rather than only describing what to do. After acting, briefly "
+ "confirm what you did in plain language. Dates are YYYY-MM-DD. If a request "
+ "is ambiguous, ask a short clarifying question instead of guessing."
+)
+
+
+def run_turn(messages: list[dict], system_prompt: str = SYSTEM_PROMPT) -> tuple[str, list[dict]]:
+ """Advance the conversation by one user turn (which may involve several tool
+ calls). `messages` should already include the latest user message. Returns
+ (assistant_reply_text, updated_messages)."""
+ for _ in range(MAX_STEPS):
+ turn = ai_client.generate_with_tools(
+ messages=messages,
+ tools=tools.tool_schemas(),
+ system_prompt=system_prompt,
+ )
+
+ if turn.tool_calls:
+ messages.append({
+ "role": "assistant",
+ "content": turn.text or "",
+ "tool_calls": [
+ {"id": tc.id, "name": tc.name, "arguments": tc.arguments}
+ for tc in turn.tool_calls
+ ],
+ })
+ for tc in turn.tool_calls:
+ result = tools.execute_tool(tc.name, tc.arguments)
+ messages.append({
+ "role": "tool",
+ "tool_call_id": tc.id,
+ "name": tc.name,
+ "content": json.dumps(result, default=str),
+ })
+ continue
+
+ reply = turn.text or ""
+ messages.append({"role": "assistant", "content": reply})
+ return reply, messages
+
+ fallback = "I stopped after several steps without finishing. Could you narrow the request?"
+ messages.append({"role": "assistant", "content": fallback})
+ return fallback, messages
+
+
+def display_messages(messages: list[dict]) -> list[dict]:
+ """Just the user/assistant text turns, for rendering the chat transcript."""
+ out = []
+ for m in messages:
+ if m["role"] == "user" and m.get("content"):
+ out.append({"role": "user", "content": m["content"]})
+ elif m["role"] == "assistant" and m.get("content") and not m.get("tool_calls"):
+ out.append({"role": "assistant", "content": m["content"]})
+ return out
diff --git a/ai_assistant/apps.py b/ai_assistant/apps.py
new file mode 100644
index 0000000..a98f76e
--- /dev/null
+++ b/ai_assistant/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class AiAssistantConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "ai_assistant"
+ verbose_name = "AI Assistant"
diff --git a/ai_assistant/management/__init__.py b/ai_assistant/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ai_assistant/management/commands/__init__.py b/ai_assistant/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ai_assistant/management/commands/connect_claude.py b/ai_assistant/management/commands/connect_claude.py
new file mode 100644
index 0000000..35a0657
--- /dev/null
+++ b/ai_assistant/management/commands/connect_claude.py
@@ -0,0 +1,29 @@
+# ai_assistant/management/commands/connect_claude.py
+import sys
+from pathlib import Path
+
+from django.core.management.base import BaseCommand
+
+from mcp_server.desktop_connect import claude_config_path, connect
+
+
+class Command(BaseCommand):
+ help = "Register this install with Claude Desktop (adds the content-calendar MCP server)."
+
+ def handle(self, *args, **options):
+ base_dir = Path(__file__).resolve().parents[3]
+ frozen = getattr(sys, "frozen", False)
+ status = connect(base_dir, frozen)
+
+ cfg = claude_config_path()
+ if status in ("connected", "updated", "unchanged"):
+ self.stdout.write(self.style.SUCCESS(
+ f"Claude Desktop: {status} ({cfg})."
+ ))
+ self.stdout.write("Fully quit and reopen Claude Desktop to load the change.")
+ elif status == "no-claude":
+ self.stdout.write(self.style.WARNING(
+ "Claude Desktop not found — nothing changed. Install Claude Desktop, then run this again."
+ ))
+ else:
+ self.stderr.write(self.style.ERROR(f"Could not connect: {status}"))
diff --git a/ai_assistant/management/commands/runmcp.py b/ai_assistant/management/commands/runmcp.py
new file mode 100644
index 0000000..1814649
--- /dev/null
+++ b/ai_assistant/management/commands/runmcp.py
@@ -0,0 +1,15 @@
+# ai_assistant/management/commands/runmcp.py
+from django.core.management.base import BaseCommand
+
+
+class Command(BaseCommand):
+ help = "Run the MCP server over stdio so Claude Desktop/Code can manage the calendar."
+
+ def handle(self, *args, **options):
+ # Django is already configured here; importing the server registers the
+ # tools against the same models the app uses. stdout is reserved for the
+ # MCP protocol, so status goes to stderr.
+ from mcp_server.server import mcp
+
+ self.stderr.write("Starting content-calendar MCP server (stdio)…")
+ mcp.run(transport="stdio")
diff --git a/ai_assistant/templates/ai_assistant/_messages.html b/ai_assistant/templates/ai_assistant/_messages.html
new file mode 100644
index 0000000..b52963f
--- /dev/null
+++ b/ai_assistant/templates/ai_assistant/_messages.html
@@ -0,0 +1,20 @@
+{% comment %}The chat transcript — returned on its own by the send endpoint and
+swapped into #chat-messages. Expects `messages` and optional `error`.{% endcomment %}
+{% for m in messages %}
+ {% if m.role == 'user' %}
+
+ {% else %}
+
+ {% endif %}
+{% empty %}
+ Ask me to plan, draft, schedule, or review your content.
+{% endfor %}
+{% if error %}
+
+{% endif %}
diff --git a/ai_assistant/templates/ai_assistant/chat.html b/ai_assistant/templates/ai_assistant/chat.html
new file mode 100644
index 0000000..77b01a8
--- /dev/null
+++ b/ai_assistant/templates/ai_assistant/chat.html
@@ -0,0 +1,100 @@
+{% extends 'base.html' %}
+{% block title %}Assistant - {{ site_config.site_name }}{% endblock %}
+
+{% block content %}
+
+
+
Assistant
+
+
+
+
+
+ {% include "ai_assistant/_messages.html" %}
+
+
+
+
+
Try: “Give me 5 August Instagram post ideas about summer skincare” or “Move item 3 to next Friday”.
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/ai_assistant/tests.py b/ai_assistant/tests.py
new file mode 100644
index 0000000..612be26
--- /dev/null
+++ b/ai_assistant/tests.py
@@ -0,0 +1,157 @@
+# ai_assistant/tests.py
+import json
+from datetime import date
+from unittest.mock import patch
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+
+from ai_settings.ai_client import ToolCall, ToolTurn
+from content_calendar.models import ContentItem
+
+from . import agent, tools
+
+
+class ToolFunctionTests(TestCase):
+ def test_create_and_list(self):
+ result = tools.create_content_item(topic="Hello world", scheduled_date="2026-08-10",
+ content_type="reel")
+ self.assertIn("id", result)
+ self.assertEqual(result["topic"], "Hello world")
+
+ listed = tools.list_content_items(start_date="2026-08-01", end_date="2026-08-31")
+ self.assertEqual(listed["count"], 1)
+
+ def test_create_requires_topic(self):
+ out = tools.execute_tool("create_content_item", {"topic": ""})
+ self.assertIn("error", out)
+
+ def test_update_status_pipeline_and_approval(self):
+ item = ContentItem.objects.create(topic="X")
+ tools.update_status(item.id, "scheduled")
+ item.refresh_from_db()
+ self.assertEqual(item.status, "scheduled")
+ tools.update_status(item.id, "approved")
+ item.refresh_from_db()
+ self.assertEqual(item.approval_status, "approved")
+
+ def test_update_status_invalid(self):
+ item = ContentItem.objects.create(topic="X")
+ out = tools.execute_tool("update_status", {"item_id": item.id, "status": "nope"})
+ self.assertIn("error", out)
+
+ def test_reschedule(self):
+ item = ContentItem.objects.create(topic="X", scheduled_date=date(2026, 8, 1))
+ tools.reschedule_content_item(item.id, "2026-08-20")
+ item.refresh_from_db()
+ self.assertEqual(item.scheduled_date, date(2026, 8, 20))
+
+ def test_bad_date_reported(self):
+ out = tools.execute_tool("reschedule_content_item", {"item_id": 1, "scheduled_date": "not-a-date"})
+ self.assertIn("error", out)
+
+ def test_get_calendar_month(self):
+ ContentItem.objects.create(topic="Aug", scheduled_date=date(2026, 8, 15))
+ ContentItem.objects.create(topic="Sep", scheduled_date=date(2026, 9, 1))
+ out = tools.get_calendar_month(2026, 8)
+ self.assertEqual(out["count"], 1)
+ self.assertEqual(out["items"][0]["topic"], "Aug")
+
+ @patch("content_calendar.generation.ai_client.generate")
+ def test_generate_draft(self, mock_generate):
+ mock_generate.return_value = json.dumps({
+ "topic": "Drafted", "hook": "H", "caption": "C",
+ "call_to_action": "CTA", "hashtags": "#x",
+ })
+ out = tools.generate_draft(brief="something", date="2026-08-05")
+ self.assertEqual(out["topic"], "Drafted")
+ self.assertEqual(out["approval_status"], "ready")
+
+ def test_unknown_tool(self):
+ self.assertIn("error", tools.execute_tool("does_not_exist", {}))
+
+ def test_schemas_cover_all_tools(self):
+ names = {s["name"] for s in tools.tool_schemas()}
+ self.assertEqual(names, set(tools.TOOLS.keys()))
+
+
+class AgentLoopTests(TestCase):
+ @patch("ai_assistant.agent.ai_client.generate_with_tools")
+ def test_loop_executes_tool_then_replies(self, mock_gwt):
+ # First call asks to create an item; second call replies in text.
+ mock_gwt.side_effect = [
+ ToolTurn(text=None, tool_calls=[
+ ToolCall(id="c1", name="create_content_item",
+ arguments={"topic": "Made by tool", "scheduled_date": "2026-08-09"})
+ ]),
+ ToolTurn(text="Done — created your post.", tool_calls=[]),
+ ]
+ reply, messages = agent.run_turn([{"role": "user", "content": "make a post"}])
+ self.assertEqual(reply, "Done — created your post.")
+ self.assertTrue(ContentItem.objects.filter(topic="Made by tool").exists())
+ # Conversation contains a tool result message.
+ self.assertTrue(any(m["role"] == "tool" for m in messages))
+
+ @patch("ai_assistant.agent.ai_client.generate_with_tools")
+ def test_loop_stops_at_max_steps(self, mock_gwt):
+ # Always returns a tool call → loop must give up after MAX_STEPS.
+ mock_gwt.return_value = ToolTurn(
+ text=None,
+ tool_calls=[ToolCall(id="c", name="list_content_items", arguments={})],
+ )
+ reply, _ = agent.run_turn([{"role": "user", "content": "loop forever"}])
+ self.assertIn("stopped", reply.lower())
+ self.assertEqual(mock_gwt.call_count, agent.MAX_STEPS)
+
+ def test_display_messages_filters_tool_traffic(self):
+ convo = [
+ {"role": "user", "content": "hi"},
+ {"role": "assistant", "content": "", "tool_calls": [{"id": "1", "name": "x", "arguments": {}}]},
+ {"role": "tool", "tool_call_id": "1", "name": "x", "content": "{}"},
+ {"role": "assistant", "content": "hello!"},
+ ]
+ shown = agent.display_messages(convo)
+ self.assertEqual(shown, [
+ {"role": "user", "content": "hi"},
+ {"role": "assistant", "content": "hello!"},
+ ])
+
+
+class ChatViewTests(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ User = get_user_model()
+ cls.user = User.objects.create_user(username="tester", password="pw12345")
+
+ def setUp(self):
+ self.client.force_login(self.user)
+
+ def test_chat_page_renders(self):
+ resp = self.client.get(reverse("ai_assistant:chat"))
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "Assistant")
+
+ @patch("ai_assistant.agent.ai_client.generate_with_tools")
+ def test_send_appends_and_returns_transcript(self, mock_gwt):
+ mock_gwt.return_value = ToolTurn(text="Hi there!", tool_calls=[])
+ resp = self.client.post(reverse("ai_assistant:send"), data={"message": "hello"})
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "hello")
+ self.assertContains(resp, "Hi there!")
+
+ def test_send_without_provider_shows_error(self):
+ resp = self.client.post(reverse("ai_assistant:send"), data={"message": "hello"})
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "No active AI provider")
+
+ def test_clear_resets_session(self):
+ self.client.post(reverse("ai_assistant:send"), data={"message": "hello"})
+ resp = self.client.post(reverse("ai_assistant:clear"))
+ self.assertEqual(resp.status_code, 302)
+ self.assertEqual(self.client.session.get("assistant_messages"), [])
+
+ def test_login_required(self):
+ self.client.logout()
+ resp = self.client.get(reverse("ai_assistant:chat"))
+ self.assertEqual(resp.status_code, 302)
diff --git a/ai_assistant/tools.py b/ai_assistant/tools.py
new file mode 100644
index 0000000..39b4f2f
--- /dev/null
+++ b/ai_assistant/tools.py
@@ -0,0 +1,279 @@
+# ai_assistant/tools.py
+"""The assistant's tool set — plain Python functions over the content calendar
+and generation engine.
+
+These are the single source of truth for what the assistant (Phase 4) and the
+MCP server (Phase 5) can do. Each function takes JSON-friendly arguments and
+returns a JSON-serializable dict, so the same functions work behind a provider's
+tool-calling API and behind MCP's @server.tool() decorators.
+"""
+from datetime import date
+
+from content_calendar import generation
+from content_calendar.models import ContentItem, ContentTemplate
+from projects.models import Project
+
+MAX_LIST = 100
+
+
+class ToolError(Exception):
+ """A tool failed in a way worth reporting back to the model/user."""
+
+
+def _parse_date(value, field="date"):
+ if not value:
+ return None
+ try:
+ return date.fromisoformat(value)
+ except (ValueError, TypeError):
+ raise ToolError(f"{field} must be YYYY-MM-DD, got {value!r}")
+
+
+def _item_dict(item: ContentItem) -> dict:
+ return {
+ "id": item.id,
+ "topic": item.topic,
+ "scheduled_date": item.scheduled_date.isoformat() if item.scheduled_date else None,
+ "content_type": item.content_type,
+ "pillar": item.pillar,
+ "hook": item.hook,
+ "caption": item.caption,
+ "call_to_action": item.call_to_action,
+ "hashtags": item.hashtags,
+ "status": item.status,
+ "approval_status": item.approval_status,
+ "platforms": [p.name for p in item.platforms.all()],
+ "project": item.project.name if item.project else None,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Tool functions
+# ---------------------------------------------------------------------------
+def create_content_item(topic, scheduled_date=None, content_type="", pillar="",
+ hook="", caption="", call_to_action="", hashtags="",
+ status="idea", approval_status="draft", project_id=None,
+ platforms=None):
+ """Create a content item with the given fields."""
+ if not topic:
+ raise ToolError("topic is required")
+ project = None
+ if project_id:
+ project = Project.objects.filter(pk=project_id).first()
+ if project is None:
+ raise ToolError(f"No project with id {project_id}")
+
+ if status not in dict(ContentItem.STATUS_CHOICES):
+ status = "idea"
+ if approval_status not in dict(ContentItem.APPROVAL_STATUS_CHOICES):
+ approval_status = "draft"
+
+ item = ContentItem.objects.create(
+ topic=topic,
+ scheduled_date=_parse_date(scheduled_date, "scheduled_date"),
+ content_type=content_type or "",
+ pillar=pillar or "",
+ hook=hook or "",
+ caption=caption or "",
+ call_to_action=call_to_action or "",
+ hashtags=hashtags or "",
+ status=status,
+ approval_status=approval_status,
+ project=project,
+ )
+ if platforms:
+ from content_calendar.models import Platform
+ item.platforms.set(Platform.objects.filter(name__in=platforms))
+ return _item_dict(item)
+
+
+def list_content_items(start_date=None, end_date=None, status=None, approval_status=None):
+ """List content items, optionally filtered by scheduled-date range/status."""
+ qs = ContentItem.objects.select_related("project").prefetch_related("platforms")
+ start = _parse_date(start_date, "start_date")
+ end = _parse_date(end_date, "end_date")
+ if start:
+ qs = qs.filter(scheduled_date__gte=start)
+ if end:
+ qs = qs.filter(scheduled_date__lte=end)
+ if status:
+ qs = qs.filter(status=status)
+ if approval_status:
+ qs = qs.filter(approval_status=approval_status)
+ qs = qs.order_by("scheduled_date", "order")[:MAX_LIST]
+ return {"count": len(qs), "items": [_item_dict(i) for i in qs]}
+
+
+def update_status(item_id, status):
+ """Set a content item's status. Accepts either a pipeline status
+ (idea/draft/scheduled/published/archived) or an approval status
+ (draft/ready/approved/changes)."""
+ item = ContentItem.objects.filter(pk=item_id).first()
+ if item is None:
+ raise ToolError(f"No content item with id {item_id}")
+ if status in dict(ContentItem.STATUS_CHOICES):
+ item.status = status
+ elif status in dict(ContentItem.APPROVAL_STATUS_CHOICES):
+ item.approval_status = status
+ else:
+ raise ToolError(f"Unknown status {status!r}")
+ item.save()
+ return _item_dict(item)
+
+
+def reschedule_content_item(item_id, scheduled_date):
+ """Move a content item to a different date (YYYY-MM-DD), or clear it (null)."""
+ item = ContentItem.objects.filter(pk=item_id).first()
+ if item is None:
+ raise ToolError(f"No content item with id {item_id}")
+ item.scheduled_date = _parse_date(scheduled_date, "scheduled_date")
+ item.save()
+ return _item_dict(item)
+
+
+def generate_draft(brief="", template_id=None, date=None):
+ """Generate a draft content item from a brief and/or a template, using the
+ active AI provider. Returns the created item."""
+ template = None
+ if template_id:
+ template = ContentTemplate.objects.filter(pk=template_id).first()
+ if template is None:
+ raise ToolError(f"No template with id {template_id}")
+ if not template and not (brief or "").strip():
+ raise ToolError("Provide a brief or a template_id")
+ item = generation.generate_content_item(
+ template=template,
+ brief=(brief or "").strip(),
+ scheduled_date=_parse_date(date, "date"),
+ )
+ return _item_dict(item)
+
+
+def distill_voice(samples):
+ """Distil writing samples into the reusable voice profile."""
+ if not (samples or "").strip():
+ raise ToolError("samples text is required")
+ voice = generation.distill_voice(samples)
+ return {
+ "summary": voice.summary,
+ "tone_words": voice.tone_words,
+ "sentence_length": voice.sentence_length,
+ "words_to_avoid": voice.words_to_avoid,
+ "enabled": voice.enabled,
+ }
+
+
+def get_calendar_month(year, month):
+ """List all content scheduled in a given month."""
+ try:
+ first = date(int(year), int(month), 1)
+ except (ValueError, TypeError):
+ raise ToolError("year and month must be valid numbers")
+ if month == 12:
+ end = date(int(year), 12, 31)
+ else:
+ from datetime import timedelta
+ end = date(int(year), int(month) + 1, 1) - timedelta(days=1)
+ return list_content_items(start_date=first.isoformat(), end_date=end.isoformat())
+
+
+# ---------------------------------------------------------------------------
+# Registry: schema (for provider tool-calling) + dispatch
+# ---------------------------------------------------------------------------
+def _schema(properties, required=None):
+ return {"type": "object", "properties": properties, "required": required or []}
+
+
+TOOLS = {
+ "create_content_item": {
+ "fn": create_content_item,
+ "description": "Create a new content item in the calendar.",
+ "parameters": _schema({
+ "topic": {"type": "string", "description": "Short topic/title"},
+ "scheduled_date": {"type": "string", "description": "YYYY-MM-DD, optional"},
+ "content_type": {"type": "string", "description": "reel, carousel, single_image, story, video, live or text"},
+ "pillar": {"type": "string"},
+ "hook": {"type": "string"},
+ "caption": {"type": "string"},
+ "call_to_action": {"type": "string"},
+ "hashtags": {"type": "string"},
+ "status": {"type": "string", "description": "idea, draft, scheduled, published or archived"},
+ "approval_status": {"type": "string", "description": "draft, ready, approved or changes"},
+ "project_id": {"type": "integer"},
+ "platforms": {"type": "array", "items": {"type": "string"}, "description": "Platform names"},
+ }, required=["topic"]),
+ },
+ "list_content_items": {
+ "fn": list_content_items,
+ "description": "List content items, optionally filtered by date range and status.",
+ "parameters": _schema({
+ "start_date": {"type": "string", "description": "YYYY-MM-DD"},
+ "end_date": {"type": "string", "description": "YYYY-MM-DD"},
+ "status": {"type": "string"},
+ "approval_status": {"type": "string"},
+ }),
+ },
+ "update_status": {
+ "fn": update_status,
+ "description": "Set a content item's pipeline status or approval status.",
+ "parameters": _schema({
+ "item_id": {"type": "integer"},
+ "status": {"type": "string", "description": "A pipeline status or an approval status"},
+ }, required=["item_id", "status"]),
+ },
+ "reschedule_content_item": {
+ "fn": reschedule_content_item,
+ "description": "Move a content item to a different scheduled date.",
+ "parameters": _schema({
+ "item_id": {"type": "integer"},
+ "scheduled_date": {"type": "string", "description": "YYYY-MM-DD, or null to unschedule"},
+ }, required=["item_id"]),
+ },
+ "generate_draft": {
+ "fn": generate_draft,
+ "description": "Generate a draft content item from a brief and/or a template.",
+ "parameters": _schema({
+ "brief": {"type": "string"},
+ "template_id": {"type": "integer"},
+ "date": {"type": "string", "description": "YYYY-MM-DD, optional"},
+ }),
+ },
+ "distill_voice": {
+ "fn": distill_voice,
+ "description": "Distil writing samples into the reusable voice profile.",
+ "parameters": _schema({
+ "samples": {"type": "string", "description": "Writing samples to analyse"},
+ }, required=["samples"]),
+ },
+ "get_calendar_month": {
+ "fn": get_calendar_month,
+ "description": "List all content scheduled in a given month.",
+ "parameters": _schema({
+ "year": {"type": "integer"},
+ "month": {"type": "integer", "description": "1-12"},
+ }, required=["year", "month"]),
+ },
+}
+
+
+def tool_schemas() -> list[dict]:
+ """The tool definitions in the adapter's normalized shape."""
+ return [
+ {"name": name, "description": spec["description"], "parameters": spec["parameters"]}
+ for name, spec in TOOLS.items()
+ ]
+
+
+def execute_tool(name: str, arguments: dict) -> dict:
+ """Run a tool by name. Always returns a dict (errors wrapped as {'error': ...})."""
+ spec = TOOLS.get(name)
+ if spec is None:
+ return {"error": f"Unknown tool: {name}"}
+ try:
+ return spec["fn"](**(arguments or {}))
+ except ToolError as exc:
+ return {"error": str(exc)}
+ except TypeError as exc:
+ return {"error": f"Bad arguments for {name}: {exc}"}
+ except Exception as exc: # noqa: BLE001 — report to the model rather than crash the loop
+ return {"error": f"{type(exc).__name__}: {exc}"}
diff --git a/ai_assistant/urls.py b/ai_assistant/urls.py
new file mode 100644
index 0000000..1dd39cd
--- /dev/null
+++ b/ai_assistant/urls.py
@@ -0,0 +1,12 @@
+# ai_assistant/urls.py
+from django.urls import path
+
+from . import views
+
+app_name = "ai_assistant"
+
+urlpatterns = [
+ path("", views.ChatView.as_view(), name="chat"),
+ path("send/", views.ChatSendView.as_view(), name="send"),
+ path("clear/", views.ChatClearView.as_view(), name="clear"),
+]
diff --git a/ai_assistant/views.py b/ai_assistant/views.py
new file mode 100644
index 0000000..a208921
--- /dev/null
+++ b/ai_assistant/views.py
@@ -0,0 +1,64 @@
+# ai_assistant/views.py
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.shortcuts import redirect, render
+from django.urls import reverse
+from django.views.generic import View
+
+from ai_settings import ai_client
+
+from . import agent
+
+SESSION_KEY = "assistant_messages"
+
+
+def _get_messages(request):
+ return request.session.get(SESSION_KEY, [])
+
+
+def _save_messages(request, messages):
+ request.session[SESSION_KEY] = messages
+ request.session.modified = True
+
+
+def _render_messages(request, error=""):
+ return render(request, "ai_assistant/_messages.html", {
+ "messages": agent.display_messages(_get_messages(request)),
+ "error": error,
+ })
+
+
+class ChatView(LoginRequiredMixin, View):
+ template_name = "ai_assistant/chat.html"
+
+ def get(self, request, *args, **kwargs):
+ return render(request, self.template_name, {
+ "messages": agent.display_messages(_get_messages(request)),
+ })
+
+
+class ChatSendView(LoginRequiredMixin, View):
+ def post(self, request, *args, **kwargs):
+ text = (request.POST.get("message") or "").strip()
+ if not text:
+ return _render_messages(request)
+
+ messages = _get_messages(request)
+ messages.append({"role": "user", "content": text})
+
+ error = ""
+ try:
+ _reply, messages = agent.run_turn(messages)
+ except ai_client.AIConfigError as exc:
+ error = f"{exc} Set one up in AI Settings."
+ except Exception as exc: # noqa: BLE001 — surface to the user, keep the app up
+ error = f"Something went wrong: {type(exc).__name__}: {exc}"
+
+ _save_messages(request, messages)
+ return _render_messages(request, error=error)
+
+
+class ChatClearView(LoginRequiredMixin, View):
+ def post(self, request, *args, **kwargs):
+ request.session[SESSION_KEY] = []
+ request.session.modified = True
+ return redirect(reverse("ai_assistant:chat"))
diff --git a/ai_settings/__init__.py b/ai_settings/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ai_settings/admin.py b/ai_settings/admin.py
new file mode 100644
index 0000000..aae6b29
--- /dev/null
+++ b/ai_settings/admin.py
@@ -0,0 +1,18 @@
+# ai_settings/admin.py
+from django.contrib import admin
+
+from .models import AIProviderConfig
+
+
+@admin.register(AIProviderConfig)
+class AIProviderConfigAdmin(admin.ModelAdmin):
+ list_display = ("provider", "model_name", "is_active", "has_key", "updated_at")
+ list_filter = ("provider", "is_active")
+ # The API key is encrypted at rest and never shown in the admin; manage it
+ # from the AI Settings page instead.
+ readonly_fields = ("has_key", "created_at", "updated_at")
+ fields = ("provider", "model_name", "is_active", "has_key", "created_at", "updated_at")
+
+ @admin.display(boolean=True, description="API key set")
+ def has_key(self, obj):
+ return obj.has_key
diff --git a/ai_settings/ai_client.py b/ai_settings/ai_client.py
new file mode 100644
index 0000000..4688d51
--- /dev/null
+++ b/ai_settings/ai_client.py
@@ -0,0 +1,321 @@
+# ai_settings/ai_client.py
+"""Thin, provider-agnostic AI adapter.
+
+Every AI feature in the app calls into this module and never touches the
+`openai` / `anthropic` / `google-genai` SDKs directly. Which provider is used is
+decided by whichever `AIProviderConfig` row is active.
+
+Two entry points:
+ - generate(system_prompt, user_prompt, temperature) -> str
+ - generate_with_tools(messages, tools, system_prompt, ...) -> ToolTurn
+
+The tool interface is normalized so callers (the Phase 4 assistant loop, the
+Phase 5 MCP server) use one shape regardless of provider:
+
+ messages: list of dicts
+ {"role": "user", "content": "..."}
+ {"role": "assistant", "content": "..."|None, "tool_calls": [ToolCall-like]}
+ {"role": "tool", "tool_call_id": "...", "name": "...", "content": "..."}
+ tools: list of {"name", "description", "parameters": }
+
+The return value (ToolTurn) exposes `.text`, `.tool_calls`, and `.raw`.
+"""
+import json
+from dataclasses import dataclass, field
+from typing import Any, Optional
+
+from .models import AIProviderConfig
+
+DEFAULT_MAX_TOKENS = 4096
+
+
+class AIConfigError(RuntimeError):
+ """No active provider, or the active provider has no API key."""
+
+
+@dataclass
+class ToolCall:
+ id: str
+ name: str
+ arguments: dict
+
+
+@dataclass
+class ToolTurn:
+ """One assistant turn: free-text and/or a set of tool calls to execute."""
+ text: Optional[str] = None
+ tool_calls: list[ToolCall] = field(default_factory=list)
+ raw: Any = None
+
+
+def _require_active() -> AIProviderConfig:
+ config = AIProviderConfig.active()
+ if config is None:
+ raise AIConfigError(
+ "No active AI provider. Set one up in AI Settings first."
+ )
+ if not config.api_key:
+ raise AIConfigError(
+ f"The active provider ({config.get_provider_display()}) has no API key."
+ )
+ return config
+
+
+# ---------------------------------------------------------------------------
+# generate() — plain text in, plain text out
+# ---------------------------------------------------------------------------
+def generate(system_prompt: str, user_prompt: str, temperature: float = 0.7) -> str:
+ config = _require_active()
+ model = config.effective_model
+ key = config.api_key
+
+ if config.provider == "openai":
+ return _openai_generate(key, model, system_prompt, user_prompt, temperature)
+ if config.provider == "anthropic":
+ return _anthropic_generate(key, model, system_prompt, user_prompt, temperature)
+ if config.provider == "gemini":
+ return _gemini_generate(key, model, system_prompt, user_prompt, temperature)
+ raise AIConfigError(f"Unknown provider: {config.provider}")
+
+
+def _openai_generate(key, model, system_prompt, user_prompt, temperature) -> str:
+ from openai import OpenAI
+
+ client = OpenAI(api_key=key)
+ resp = client.chat.completions.create(
+ model=model,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt},
+ ],
+ temperature=temperature,
+ )
+ return resp.choices[0].message.content or ""
+
+
+def _anthropic_generate(key, model, system_prompt, user_prompt, temperature) -> str:
+ import anthropic
+
+ client = anthropic.Anthropic(api_key=key)
+ resp = client.messages.create(
+ model=model,
+ system=system_prompt or "",
+ max_tokens=DEFAULT_MAX_TOKENS,
+ temperature=temperature,
+ messages=[{"role": "user", "content": user_prompt}],
+ )
+ return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
+
+
+def _gemini_generate(key, model, system_prompt, user_prompt, temperature) -> str:
+ from google import genai
+ from google.genai import types
+
+ client = genai.Client(api_key=key)
+ resp = client.models.generate_content(
+ model=model,
+ contents=user_prompt,
+ config=types.GenerateContentConfig(
+ system_instruction=system_prompt or None,
+ temperature=temperature,
+ ),
+ )
+ return resp.text or ""
+
+
+# ---------------------------------------------------------------------------
+# generate_with_tools() — normalized function/tool calling
+# ---------------------------------------------------------------------------
+def generate_with_tools(
+ messages: list[dict],
+ tools: list[dict],
+ system_prompt: str = "",
+ temperature: float = 0.7,
+ max_tokens: int = DEFAULT_MAX_TOKENS,
+) -> ToolTurn:
+ config = _require_active()
+ model = config.effective_model
+ key = config.api_key
+
+ if config.provider == "openai":
+ return _openai_tools(key, model, messages, tools, system_prompt, temperature)
+ if config.provider == "anthropic":
+ return _anthropic_tools(key, model, messages, tools, system_prompt, temperature, max_tokens)
+ if config.provider == "gemini":
+ return _gemini_tools(key, model, messages, tools, system_prompt, temperature)
+ raise AIConfigError(f"Unknown provider: {config.provider}")
+
+
+def _empty_schema() -> dict:
+ return {"type": "object", "properties": {}}
+
+
+def _openai_tools(key, model, messages, tools, system_prompt, temperature) -> ToolTurn:
+ from openai import OpenAI
+
+ client = OpenAI(api_key=key)
+ oai_tools = [
+ {
+ "type": "function",
+ "function": {
+ "name": t["name"],
+ "description": t.get("description", ""),
+ "parameters": t.get("parameters") or _empty_schema(),
+ },
+ }
+ for t in tools
+ ]
+
+ oai_messages = []
+ if system_prompt:
+ oai_messages.append({"role": "system", "content": system_prompt})
+ for m in messages:
+ role = m["role"]
+ if role == "tool":
+ oai_messages.append({
+ "role": "tool",
+ "tool_call_id": m.get("tool_call_id", ""),
+ "content": m.get("content", ""),
+ })
+ elif role == "assistant" and m.get("tool_calls"):
+ oai_messages.append({
+ "role": "assistant",
+ "content": m.get("content"),
+ "tool_calls": [
+ {
+ "id": tc["id"],
+ "type": "function",
+ "function": {"name": tc["name"], "arguments": json.dumps(tc["arguments"])},
+ }
+ for tc in m["tool_calls"]
+ ],
+ })
+ else:
+ oai_messages.append({"role": role, "content": m.get("content", "")})
+
+ resp = client.chat.completions.create(
+ model=model,
+ messages=oai_messages,
+ tools=oai_tools or None,
+ temperature=temperature,
+ )
+ msg = resp.choices[0].message
+ calls = [
+ ToolCall(id=tc.id, name=tc.function.name, arguments=json.loads(tc.function.arguments or "{}"))
+ for tc in (msg.tool_calls or [])
+ ]
+ return ToolTurn(text=msg.content, tool_calls=calls, raw=resp)
+
+
+def _anthropic_tools(key, model, messages, tools, system_prompt, temperature, max_tokens) -> ToolTurn:
+ import anthropic
+
+ client = anthropic.Anthropic(api_key=key)
+ a_tools = [
+ {
+ "name": t["name"],
+ "description": t.get("description", ""),
+ "input_schema": t.get("parameters") or _empty_schema(),
+ }
+ for t in tools
+ ]
+
+ a_messages = []
+ for m in messages:
+ role = m["role"]
+ if role == "tool":
+ a_messages.append({
+ "role": "user",
+ "content": [{
+ "type": "tool_result",
+ "tool_use_id": m.get("tool_call_id", ""),
+ "content": m.get("content", ""),
+ }],
+ })
+ elif role == "assistant" and m.get("tool_calls"):
+ blocks = []
+ if m.get("content"):
+ blocks.append({"type": "text", "text": m["content"]})
+ for tc in m["tool_calls"]:
+ blocks.append({
+ "type": "tool_use",
+ "id": tc["id"],
+ "name": tc["name"],
+ "input": tc["arguments"],
+ })
+ a_messages.append({"role": "assistant", "content": blocks})
+ else:
+ a_messages.append({"role": role, "content": m.get("content", "")})
+
+ resp = client.messages.create(
+ model=model,
+ system=system_prompt or "",
+ max_tokens=max_tokens,
+ temperature=temperature,
+ tools=a_tools or anthropic.NOT_GIVEN,
+ messages=a_messages,
+ )
+ text_parts = [b.text for b in resp.content if getattr(b, "type", None) == "text"]
+ calls = [
+ ToolCall(id=b.id, name=b.name, arguments=dict(b.input))
+ for b in resp.content if getattr(b, "type", None) == "tool_use"
+ ]
+ return ToolTurn(text="".join(text_parts) or None, tool_calls=calls, raw=resp)
+
+
+def _gemini_tools(key, model, messages, tools, system_prompt, temperature) -> ToolTurn:
+ from google import genai
+ from google.genai import types
+
+ client = genai.Client(api_key=key)
+ fn_decls = [
+ types.FunctionDeclaration(
+ name=t["name"],
+ description=t.get("description", ""),
+ parameters=t.get("parameters") or _empty_schema(),
+ )
+ for t in tools
+ ]
+ gem_tools = [types.Tool(function_declarations=fn_decls)] if fn_decls else None
+
+ contents = []
+ for m in messages:
+ role = m["role"]
+ if role == "tool":
+ contents.append(types.Content(
+ role="user",
+ parts=[types.Part.from_function_response(
+ name=m.get("name", ""),
+ response={"result": m.get("content", "")},
+ )],
+ ))
+ elif role == "assistant" and m.get("tool_calls"):
+ parts = []
+ if m.get("content"):
+ parts.append(types.Part(text=m["content"]))
+ for tc in m["tool_calls"]:
+ parts.append(types.Part.from_function_call(name=tc["name"], args=tc["arguments"]))
+ contents.append(types.Content(role="model", parts=parts))
+ else:
+ gem_role = "model" if role == "assistant" else "user"
+ contents.append(types.Content(role=gem_role, parts=[types.Part(text=m.get("content", ""))]))
+
+ resp = client.models.generate_content(
+ model=model,
+ contents=contents,
+ config=types.GenerateContentConfig(
+ system_instruction=system_prompt or None,
+ temperature=temperature,
+ tools=gem_tools,
+ ),
+ )
+ text_parts, calls = [], []
+ candidate = resp.candidates[0] if resp.candidates else None
+ for part in (candidate.content.parts if candidate and candidate.content else []):
+ if getattr(part, "function_call", None):
+ fc = part.function_call
+ # Gemini has no call ids; use the function name as a stable id.
+ calls.append(ToolCall(id=fc.name, name=fc.name, arguments=dict(fc.args or {})))
+ elif getattr(part, "text", None):
+ text_parts.append(part.text)
+ return ToolTurn(text="".join(text_parts) or None, tool_calls=calls, raw=resp)
diff --git a/ai_settings/apps.py b/ai_settings/apps.py
new file mode 100644
index 0000000..1f4212d
--- /dev/null
+++ b/ai_settings/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class AiSettingsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "ai_settings"
+ verbose_name = "AI Settings"
diff --git a/ai_settings/encryption.py b/ai_settings/encryption.py
new file mode 100644
index 0000000..51a5a26
--- /dev/null
+++ b/ai_settings/encryption.py
@@ -0,0 +1,36 @@
+# ai_settings/encryption.py
+"""Symmetric encryption for API keys stored in the local SQLite file.
+
+The key is derived from Django's SECRET_KEY, so the same install that owns the
+database can decrypt its own keys without any extra secret to manage — which
+suits a single-user, self-hosted download. If SECRET_KEY changes, previously
+stored API keys can no longer be decrypted and must be re-entered.
+"""
+import base64
+import hashlib
+
+from cryptography.fernet import Fernet, InvalidToken
+from django.conf import settings
+
+
+def _fernet() -> Fernet:
+ # Fernet needs a 32-byte urlsafe-base64 key; derive one deterministically
+ # from SECRET_KEY with SHA-256.
+ digest = hashlib.sha256(settings.SECRET_KEY.encode("utf-8")).digest()
+ return Fernet(base64.urlsafe_b64encode(digest))
+
+
+def encrypt(plaintext: str) -> str:
+ if not plaintext:
+ return ""
+ return _fernet().encrypt(plaintext.encode("utf-8")).decode("utf-8")
+
+
+def decrypt(token: str) -> str:
+ if not token:
+ return ""
+ try:
+ return _fernet().decrypt(token.encode("utf-8")).decode("utf-8")
+ except (InvalidToken, ValueError):
+ # SECRET_KEY changed or the stored value is corrupt/plaintext.
+ return ""
diff --git a/ai_settings/migrations/0001_initial.py b/ai_settings/migrations/0001_initial.py
new file mode 100644
index 0000000..02dcfe7
--- /dev/null
+++ b/ai_settings/migrations/0001_initial.py
@@ -0,0 +1,31 @@
+# Generated by Django 5.2.9 on 2026-07-30 10:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='AIProviderConfig',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('provider', models.CharField(choices=[('openai', 'OpenAI'), ('anthropic', 'Anthropic (Claude)'), ('gemini', 'Google Gemini')], max_length=20, unique=True)),
+ ('api_key_encrypted', models.TextField(blank=True, default='')),
+ ('model_name', models.CharField(blank=True, max_length=100)),
+ ('is_active', models.BooleanField(default=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'verbose_name': 'AI provider config',
+ 'verbose_name_plural': 'AI provider configs',
+ 'ordering': ['provider'],
+ },
+ ),
+ ]
diff --git a/ai_settings/migrations/__init__.py b/ai_settings/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ai_settings/models.py b/ai_settings/models.py
new file mode 100644
index 0000000..70332a6
--- /dev/null
+++ b/ai_settings/models.py
@@ -0,0 +1,77 @@
+# ai_settings/models.py
+from django.db import models
+
+from .encryption import decrypt, encrypt
+
+
+class AIProviderConfig(models.Model):
+ """One row per AI provider. `is_active` marks which one every AI feature
+ (generation engine, chat assistant, MCP tools) routes through. The API key
+ is encrypted at rest — see ai_settings.encryption.
+ """
+
+ PROVIDER_CHOICES = [
+ ("openai", "OpenAI"),
+ ("anthropic", "Anthropic (Claude)"),
+ ("gemini", "Google Gemini"),
+ ]
+
+ # Sensible current defaults, shown as the model_name placeholder per provider.
+ DEFAULT_MODELS = {
+ "openai": "gpt-4o",
+ "anthropic": "claude-sonnet-4-5",
+ "gemini": "gemini-2.0-flash",
+ }
+
+ provider = models.CharField(max_length=20, choices=PROVIDER_CHOICES, unique=True)
+ # Encrypted at rest; never store the plaintext key. Access via the `api_key`
+ # property below, which transparently encrypts/decrypts.
+ api_key_encrypted = models.TextField(blank=True, default="")
+ model_name = models.CharField(max_length=100, blank=True)
+ is_active = models.BooleanField(default=False)
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ verbose_name = "AI provider config"
+ verbose_name_plural = "AI provider configs"
+ ordering = ["provider"]
+
+ def __str__(self):
+ active = " (active)" if self.is_active else ""
+ return f"{self.get_provider_display()}{active}"
+
+ # -- API key: encrypt on set, decrypt on get -----------------------------
+ @property
+ def api_key(self) -> str:
+ return decrypt(self.api_key_encrypted)
+
+ @api_key.setter
+ def api_key(self, value: str):
+ self.api_key_encrypted = encrypt(value or "")
+
+ @property
+ def has_key(self) -> bool:
+ return bool(self.api_key_encrypted)
+
+ @property
+ def default_model(self) -> str:
+ return self.DEFAULT_MODELS.get(self.provider, "")
+
+ @property
+ def effective_model(self) -> str:
+ return self.model_name or self.default_model
+
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+ # Only one provider active at a time.
+ if self.is_active:
+ AIProviderConfig.objects.exclude(pk=self.pk).filter(is_active=True).update(
+ is_active=False
+ )
+
+ @classmethod
+ def active(cls):
+ """Return the active provider config, or None."""
+ return cls.objects.filter(is_active=True).first()
diff --git a/ai_settings/templates/ai_settings/settings.html b/ai_settings/templates/ai_settings/settings.html
new file mode 100644
index 0000000..ee4f792
--- /dev/null
+++ b/ai_settings/templates/ai_settings/settings.html
@@ -0,0 +1,94 @@
+{% extends 'base.html' %}
+{% block title %}AI Settings - {{ site_config.site_name }}{% endblock %}
+
+{% block content %}
+
+
AI Settings
+
Add a key for each provider you use, then pick which one is active. Your keys are encrypted before they're stored.
+
+ {% if saved %}
+
Settings saved.
+ {% endif %}
+
+
+ {% csrf_token %}
+ {% for config in configs %}
+
+
+
{{ config.get_provider_display }}
+
+
+ Active
+
+
+
+
+
+ API key
+
+
+
+
Model
+
+
Leave blank to use the default.
+
+
+
+ {% endfor %}
+
+
+ Save settings
+ Test connection
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/ai_settings/tests.py b/ai_settings/tests.py
new file mode 100644
index 0000000..2b5ea3f
--- /dev/null
+++ b/ai_settings/tests.py
@@ -0,0 +1,120 @@
+# ai_settings/tests.py
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+
+from . import ai_client
+from .encryption import decrypt, encrypt
+from .models import AIProviderConfig
+
+
+class EncryptionTests(TestCase):
+ def test_round_trip(self):
+ secret = "sk-test-1234567890"
+ token = encrypt(secret)
+ self.assertNotEqual(token, secret)
+ self.assertEqual(decrypt(token), secret)
+
+ def test_empty_values(self):
+ self.assertEqual(encrypt(""), "")
+ self.assertEqual(decrypt(""), "")
+
+ def test_decrypt_garbage_returns_empty(self):
+ self.assertEqual(decrypt("not-a-valid-token"), "")
+
+
+class AIProviderConfigTests(TestCase):
+ def test_api_key_encrypted_at_rest(self):
+ config = AIProviderConfig(provider="openai")
+ config.api_key = "sk-secret"
+ config.save()
+ # The stored column is not the plaintext.
+ self.assertNotIn("sk-secret", config.api_key_encrypted)
+ # But the property decrypts it back.
+ config.refresh_from_db()
+ self.assertEqual(config.api_key, "sk-secret")
+ self.assertTrue(config.has_key)
+
+ def test_only_one_active(self):
+ a = AIProviderConfig.objects.create(provider="openai", is_active=True)
+ b = AIProviderConfig.objects.create(provider="anthropic", is_active=True)
+ a.refresh_from_db()
+ self.assertFalse(a.is_active)
+ self.assertTrue(b.is_active)
+ self.assertEqual(AIProviderConfig.active(), b)
+
+ def test_effective_model_falls_back_to_default(self):
+ config = AIProviderConfig.objects.create(provider="gemini")
+ self.assertEqual(config.effective_model, config.default_model)
+ config.model_name = "gemini-custom"
+ self.assertEqual(config.effective_model, "gemini-custom")
+
+
+class AIClientTests(TestCase):
+ def test_generate_raises_without_active_provider(self):
+ with self.assertRaises(ai_client.AIConfigError):
+ ai_client.generate("sys", "hi")
+
+ def test_generate_raises_without_key(self):
+ AIProviderConfig.objects.create(provider="openai", is_active=True)
+ with self.assertRaises(ai_client.AIConfigError):
+ ai_client.generate("sys", "hi")
+
+
+class AISettingsViewTests(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ User = get_user_model()
+ cls.user = User.objects.create_user(username="tester", password="pw12345")
+
+ def setUp(self):
+ self.client.force_login(self.user)
+
+ def test_settings_page_creates_rows(self):
+ resp = self.client.get(reverse("ai_settings:settings"))
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(AIProviderConfig.objects.count(), 3)
+
+ def test_save_sets_key_and_active(self):
+ self.client.get(reverse("ai_settings:settings")) # create rows
+ resp = self.client.post(reverse("ai_settings:settings"), data={
+ "active_provider": "anthropic",
+ "api_key_anthropic": "sk-ant-xyz",
+ "model_name_anthropic": "claude-custom",
+ "api_key_openai": "",
+ "model_name_openai": "",
+ "api_key_gemini": "",
+ "model_name_gemini": "",
+ })
+ self.assertEqual(resp.status_code, 302)
+ anthropic = AIProviderConfig.objects.get(provider="anthropic")
+ self.assertTrue(anthropic.is_active)
+ self.assertEqual(anthropic.api_key, "sk-ant-xyz")
+ self.assertEqual(anthropic.model_name, "claude-custom")
+
+ def test_blank_key_preserves_existing(self):
+ self.client.get(reverse("ai_settings:settings"))
+ openai = AIProviderConfig.objects.get(provider="openai")
+ openai.api_key = "sk-original"
+ openai.save()
+ self.client.post(reverse("ai_settings:settings"), data={
+ "active_provider": "openai",
+ "api_key_openai": "", # blank = keep
+ "model_name_openai": "",
+ "api_key_anthropic": "",
+ "model_name_anthropic": "",
+ "api_key_gemini": "",
+ "model_name_gemini": "",
+ })
+ openai.refresh_from_db()
+ self.assertEqual(openai.api_key, "sk-original")
+
+ def test_test_connection_reports_config_error(self):
+ resp = self.client.post(reverse("ai_settings:test_connection"))
+ self.assertEqual(resp.status_code, 400)
+ self.assertFalse(resp.json()["ok"])
+
+ def test_login_required(self):
+ self.client.logout()
+ resp = self.client.get(reverse("ai_settings:settings"))
+ self.assertEqual(resp.status_code, 302)
diff --git a/ai_settings/urls.py b/ai_settings/urls.py
new file mode 100644
index 0000000..975cb7f
--- /dev/null
+++ b/ai_settings/urls.py
@@ -0,0 +1,11 @@
+# ai_settings/urls.py
+from django.urls import path
+
+from . import views
+
+app_name = "ai_settings"
+
+urlpatterns = [
+ path("", views.AISettingsView.as_view(), name="settings"),
+ path("test/", views.AITestConnectionView.as_view(), name="test_connection"),
+]
diff --git a/ai_settings/views.py b/ai_settings/views.py
new file mode 100644
index 0000000..bc74d68
--- /dev/null
+++ b/ai_settings/views.py
@@ -0,0 +1,61 @@
+# ai_settings/views.py
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import JsonResponse
+from django.shortcuts import redirect
+from django.urls import reverse
+from django.views.generic import TemplateView, View
+
+from . import ai_client
+from .models import AIProviderConfig
+
+
+def _ensure_rows():
+ """Make sure there's a config row for each provider so the page can render
+ all three regardless of what's been set up yet."""
+ for provider, _label in AIProviderConfig.PROVIDER_CHOICES:
+ AIProviderConfig.objects.get_or_create(provider=provider)
+
+
+class AISettingsView(LoginRequiredMixin, TemplateView):
+ template_name = "ai_settings/settings.html"
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ _ensure_rows()
+ configs = list(AIProviderConfig.objects.all())
+ ctx["configs"] = configs
+ ctx["active_provider"] = next(
+ (c.provider for c in configs if c.is_active), ""
+ )
+ ctx["saved"] = self.request.GET.get("saved") == "1"
+ return ctx
+
+ def post(self, request, *args, **kwargs):
+ _ensure_rows()
+ active = request.POST.get("active_provider", "")
+ for config in AIProviderConfig.objects.all():
+ config.model_name = request.POST.get(f"model_name_{config.provider}", "").strip()
+ # Only overwrite the key when a new value is typed; blank leaves it.
+ new_key = request.POST.get(f"api_key_{config.provider}", "").strip()
+ if new_key:
+ config.api_key = new_key
+ config.is_active = config.provider == active
+ config.save()
+ return redirect(reverse("ai_settings:settings") + "?saved=1")
+
+
+class AITestConnectionView(LoginRequiredMixin, View):
+ """Call the active provider with a trivial prompt and return its reply."""
+
+ def post(self, request, *args, **kwargs):
+ try:
+ reply = ai_client.generate(
+ system_prompt="You are a helpful assistant. Answer briefly.",
+ user_prompt="Reply with a short friendly hello and name the AI model you are.",
+ temperature=0.3,
+ )
+ return JsonResponse({"ok": True, "reply": reply})
+ except ai_client.AIConfigError as exc:
+ return JsonResponse({"ok": False, "error": str(exc)}, status=400)
+ except Exception as exc: # noqa: BLE001 — surface any SDK/network error to the user
+ return JsonResponse({"ok": False, "error": f"{type(exc).__name__}: {exc}"}, status=502)
diff --git a/config/settings.py b/config/settings.py
index 9fcfb55..bea6545 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -75,6 +75,9 @@
"assets",
"products",
"sequences",
+ "content_calendar",
+ "ai_settings",
+ "ai_assistant",
]
MIDDLEWARE = [
diff --git a/config/urls.py b/config/urls.py
index 3846a53..1b14e87 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -28,6 +28,9 @@ def redirect_to_admin_login(request):
path("pages/", include("pages.urls")),
path("assets/", include("assets.urls")),
path("products/", include("products.urls")),
+ path("content/", include("content_calendar.urls")),
+ path("ai-settings/", include("ai_settings.urls")),
+ path("assistant/", include("ai_assistant.urls")),
# login redirection
path("accounts/login/", redirect_to_admin_login, name="login"),
path(
diff --git a/content_calendar/__init__.py b/content_calendar/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/content_calendar/admin.py b/content_calendar/admin.py
new file mode 100644
index 0000000..75e8083
--- /dev/null
+++ b/content_calendar/admin.py
@@ -0,0 +1,61 @@
+# content_calendar/admin.py
+from django.contrib import admin
+
+from .models import (
+ ContentItem,
+ ContentTemplate,
+ ContentTemplatePrompt,
+ Platform,
+ VoiceProfile,
+)
+
+
+@admin.register(Platform)
+class PlatformAdmin(admin.ModelAdmin):
+ list_display = ("name", "order")
+ list_editable = ("order",)
+
+
+@admin.register(ContentItem)
+class ContentItemAdmin(admin.ModelAdmin):
+ list_display = (
+ "__str__", "scheduled_date", "content_type", "status",
+ "approval_status", "project",
+ )
+ list_filter = ("status", "approval_status", "content_type", "platforms", "project", "scheduled_date")
+ list_editable = ("status", "approval_status")
+ search_fields = ("topic", "hook", "caption", "pillar")
+ date_hierarchy = "scheduled_date"
+ filter_horizontal = ("platforms",)
+ fieldsets = (
+ ("Planning", {
+ "fields": ("project", "scheduled_date", "pillar", "content_type", "topic", "platforms"),
+ }),
+ ("Content", {
+ "fields": ("hook", "caption", "call_to_action", "hashtags", "tags_links",
+ "audio_sound", "image_video_cover", "content_link"),
+ }),
+ ("Status", {
+ "fields": ("status", "approval_status", "order"),
+ }),
+ )
+
+
+class ContentTemplatePromptInline(admin.TabularInline):
+ model = ContentTemplatePrompt
+ extra = 1
+ fields = ("order", "name", "target_field", "prompt")
+ ordering = ("order",)
+
+
+@admin.register(ContentTemplate)
+class ContentTemplateAdmin(admin.ModelAdmin):
+ list_display = ("name", "content_type", "updated_at")
+ search_fields = ("name", "description", "keywords")
+ filter_horizontal = ("default_platforms",)
+ inlines = [ContentTemplatePromptInline]
+
+
+@admin.register(VoiceProfile)
+class VoiceProfileAdmin(admin.ModelAdmin):
+ list_display = ("__str__", "enabled", "sentence_length", "distilled_at")
diff --git a/content_calendar/apps.py b/content_calendar/apps.py
new file mode 100644
index 0000000..84fbc26
--- /dev/null
+++ b/content_calendar/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class ContentCalendarConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "content_calendar"
+ verbose_name = "Content Calendar"
diff --git a/content_calendar/forms.py b/content_calendar/forms.py
new file mode 100644
index 0000000..8532548
--- /dev/null
+++ b/content_calendar/forms.py
@@ -0,0 +1,92 @@
+# content_calendar/forms.py
+from django import forms
+
+from projects.models import Project
+
+from .models import ContentItem, ContentTemplate
+
+_INPUT = (
+ "mt-1 block w-full border border-gray-300 rounded-md py-2 px-3 "
+ "focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
+)
+_SELECT = "bg-white " + _INPUT
+
+
+class ContentItemForm(forms.ModelForm):
+ class Meta:
+ model = ContentItem
+ fields = [
+ "project", "scheduled_date", "pillar", "content_type", "topic",
+ "platforms", "hook", "caption", "call_to_action", "hashtags",
+ "tags_links", "audio_sound", "image_video_cover", "content_link",
+ "status", "approval_status",
+ ]
+ widgets = {
+ "scheduled_date": forms.DateInput(
+ attrs={"type": "date", "class": _INPUT}, format="%Y-%m-%d"
+ ),
+ "hook": forms.Textarea(attrs={"rows": 2, "class": _INPUT}),
+ "caption": forms.Textarea(attrs={"rows": 4, "class": _INPUT}),
+ "hashtags": forms.Textarea(attrs={"rows": 2, "class": _INPUT}),
+ "tags_links": forms.Textarea(attrs={"rows": 2, "class": _INPUT}),
+ "platforms": forms.CheckboxSelectMultiple(),
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # The date widget needs the format set so an existing value repopulates.
+ self.fields["scheduled_date"].input_formats = ["%Y-%m-%d"]
+ # Apply consistent styling to any field that didn't get a custom widget.
+ for name, field in self.fields.items():
+ widget = field.widget
+ if isinstance(widget, forms.CheckboxSelectMultiple):
+ continue
+ css = _SELECT if isinstance(widget, forms.Select) else _INPUT
+ existing = widget.attrs.get("class", "")
+ if "border" not in existing: # don't double-apply to custom widgets
+ widget.attrs["class"] = (existing + " " + css).strip()
+
+
+class GenerateForm(forms.Form):
+ """The one-shot 'Generate' panel: pick a template or write a brief, pick a
+ date, and produce a draft ContentItem."""
+
+ template = forms.ModelChoiceField(
+ queryset=ContentTemplate.objects.all(), required=False,
+ empty_label="— None (use the brief) —",
+ widget=forms.Select(attrs={"class": _SELECT}),
+ )
+ brief = forms.CharField(
+ required=False,
+ widget=forms.Textarea(attrs={"rows": 4, "class": _INPUT,
+ "placeholder": "e.g. A post about summer skincare for busy parents"}),
+ help_text="Required if no template is chosen. Also used as extra steering for a template.",
+ )
+ content_type = forms.ChoiceField(
+ choices=[("", "—")] + ContentItem.CONTENT_TYPE_CHOICES, required=False,
+ widget=forms.Select(attrs={"class": _SELECT}),
+ )
+ scheduled_date = forms.DateField(
+ required=False,
+ widget=forms.DateInput(attrs={"type": "date", "class": _INPUT}, format="%Y-%m-%d"),
+ )
+ project = forms.ModelChoiceField(
+ queryset=Project.objects.all(), required=False, empty_label="— No project —",
+ widget=forms.Select(attrs={"class": _SELECT}),
+ )
+
+ def clean(self):
+ cleaned = super().clean()
+ if not cleaned.get("template") and not cleaned.get("brief", "").strip():
+ raise forms.ValidationError("Choose a template or write a brief (or both).")
+ return cleaned
+
+
+class VoiceProfileForm(forms.Form):
+ """Paste writing samples to distill into the voice profile."""
+
+ sample_text = forms.CharField(
+ widget=forms.Textarea(attrs={"rows": 12, "class": _INPUT,
+ "placeholder": "Paste a few paragraphs of your own writing…"}),
+ help_text="A few hundred words works best.",
+ )
diff --git a/content_calendar/generation.py b/content_calendar/generation.py
new file mode 100644
index 0000000..277ffe8
--- /dev/null
+++ b/content_calendar/generation.py
@@ -0,0 +1,282 @@
+# content_calendar/generation.py
+"""Content generation engine.
+
+Ported/adapted from ai-marketing's content_generation + content_templates:
+ - PromptEnhancer: prompt layering (content-type system prompt + voice profile)
+ - distill_voice(): turn writing samples into a structured VoiceProfile
+ - generate_content_item(): run a template (ordered per-field prompts) or a
+ free-text brief and land the result in a draft ContentItem.
+
+Every model call goes through ai_settings.ai_client.generate() so the engine is
+provider-agnostic — it never imports openai/anthropic/google directly.
+"""
+import json
+import re
+
+from ai_settings import ai_client
+
+from .models import ContentItem, VoiceProfile
+
+
+# ---------------------------------------------------------------------------
+# Prompt layering (adapted from prompt_enhancement.PromptEnhancer)
+# ---------------------------------------------------------------------------
+class PromptEnhancer:
+ """Builds the (system_prompt, user_prompt) pair sent to the active provider.
+
+ Unlike the OpenAI-only original, this returns a tuple for
+ ai_client.generate() rather than an OpenAI `messages` list, and layers in
+ the distilled voice profile when one is enabled.
+ """
+
+ GENERAL_TEMPLATE = (
+ "You are a professional content creator. Write high-quality, natural, "
+ "engaging content that matches the requested platform and tone. Use short "
+ "sentences and active voice. Never use emojis unless explicitly asked. "
+ "Return only the requested content with no preamble or explanation."
+ )
+
+ SOCIAL_TEMPLATE = (
+ "You are a social media content creator. Write concise, authentic content "
+ "for the specified platform, with a strong hook and a clear call to action "
+ "where appropriate. Keep it punchy and scroll-stopping. Return only the "
+ "requested content with no preamble or explanation."
+ )
+
+ BLOG_TEMPLATE = (
+ "You are a blog content creator. Write a clear, well-structured piece with "
+ "an engaging opening, short paragraphs, and markdown headings where useful. "
+ "Return only the requested content with no preamble or explanation."
+ )
+
+ EMAIL_TEMPLATE = (
+ "You are an email marketing specialist. Write a concise, scannable email "
+ "with a compelling opening and a clear call to action. Return only the "
+ "requested content with no preamble or explanation."
+ )
+
+ TEMPLATES = {
+ "general": GENERAL_TEMPLATE,
+ "social": SOCIAL_TEMPLATE,
+ "blog": BLOG_TEMPLATE,
+ "email": EMAIL_TEMPLATE,
+ }
+
+ # Map ContentItem content types onto the prompt categories above. This is a
+ # social content calendar, so post formats default to the social template.
+ CONTENT_TYPE_CATEGORY = {
+ "reel": "social",
+ "carousel": "social",
+ "single_image": "social",
+ "story": "social",
+ "video": "social",
+ "live": "social",
+ "text": "social",
+ }
+
+ @classmethod
+ def category_for(cls, content_type: str) -> str:
+ """Resolve a ContentItem content type (or a raw category) to a prompt
+ category. Falls back to 'social' for this app's domain."""
+ if content_type in cls.TEMPLATES:
+ return content_type
+ return cls.CONTENT_TYPE_CATEGORY.get(content_type, "social")
+
+ @classmethod
+ def detect_content_type(cls, text: str) -> str:
+ t = (text or "").lower()
+ if any(kw in t for kw in ["blog", "article"]):
+ return "blog"
+ if any(kw in t for kw in ["email", "newsletter", "subject line"]):
+ return "email"
+ if any(kw in t for kw in ["reel", "story", "carousel", "caption", "hook",
+ "hashtag", "instagram", "tiktok", "social", "post"]):
+ return "social"
+ return "general"
+
+ @classmethod
+ def voice_block(cls, voice: VoiceProfile | None) -> str:
+ """Render the distilled voice profile as system-prompt guidance."""
+ if not voice or not voice.enabled or not voice.is_distilled:
+ return ""
+ parts = ["\n\nWrite in this specific brand voice:"]
+ if voice.summary:
+ parts.append(f"- Voice: {voice.summary}")
+ if voice.tone_words:
+ parts.append(f"- Tone: {', '.join(voice.tone_words)}")
+ if voice.sentence_length:
+ parts.append(f"- Sentence length: {voice.get_sentence_length_display()}")
+ if voice.words_to_avoid:
+ parts.append(f"- Never use: {', '.join(voice.words_to_avoid)}")
+ for note in voice.do_notes:
+ parts.append(f"- Do: {note}")
+ for note in voice.dont_notes:
+ parts.append(f"- Don't: {note}")
+ return "\n".join(parts)
+
+ @classmethod
+ def build(cls, user_prompt: str, content_type: str = "general",
+ context: str = "", voice: VoiceProfile | None = None) -> tuple[str, str]:
+ """Return (system_prompt, user_prompt) for ai_client.generate()."""
+ key = content_type if content_type in cls.TEMPLATES else "general"
+ system = cls.TEMPLATES[key] + cls.voice_block(voice)
+
+ user = user_prompt
+ if context:
+ user = f"{user_prompt}\n\nContext so far (stay consistent with this):\n{context}"
+ return system, user
+
+
+# ---------------------------------------------------------------------------
+# Voice distillation (adapted from services.distill_voice)
+# ---------------------------------------------------------------------------
+VOICE_DISTILL_SYSTEM = """You are a brand-voice analyst. Read the writing \
+samples below and distill the author's distinctive writing voice into a \
+structured profile.
+
+Return ONLY valid JSON with exactly these keys:
+{
+ "summary": "one or two sentence description of the voice",
+ "tone_words": ["5-8 adjectives that describe the tone"],
+ "sentence_length": "one of: short, medium, long, varied",
+ "words_to_avoid": ["words or phrases this author would clearly never use"],
+ "sample_paragraphs": ["2-3 short paragraphs written in the author's voice"],
+ "do_notes": ["concrete do's for writing in this voice"],
+ "dont_notes": ["concrete don'ts for writing in this voice"]
+}
+
+Base every field strictly on evidence in the samples. Do not invent \
+biographical facts about the author. If the samples are thin, keep the \
+profile modest rather than fabricating detail."""
+
+
+def _extract_json(raw: str) -> dict:
+ """Parse a JSON object out of a model reply, tolerating markdown fences."""
+ if not raw:
+ return {}
+ fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.DOTALL)
+ candidate = fenced.group(1) if fenced else raw
+ try:
+ return json.loads(candidate)
+ except json.JSONDecodeError:
+ brace = re.search(r"\{.*\}", candidate, re.DOTALL)
+ if brace:
+ try:
+ return json.loads(brace.group(0))
+ except json.JSONDecodeError:
+ return {}
+ return {}
+
+
+_VALID_SENTENCE_LENGTHS = {"short", "medium", "long", "varied"}
+
+
+def distill_voice(sample_text: str, temperature: float = 0.4) -> VoiceProfile:
+ """Distill writing samples into the singleton VoiceProfile and save it."""
+ raw = ai_client.generate(VOICE_DISTILL_SYSTEM,
+ f"Writing samples:\n\n{sample_text}", temperature)
+ data = _extract_json(raw)
+
+ voice = VoiceProfile.get_solo()
+ voice.summary = data.get("summary", "") or ""
+ voice.tone_words = data.get("tone_words", []) or []
+ sentence_length = (data.get("sentence_length") or "varied").lower()
+ voice.sentence_length = sentence_length if sentence_length in _VALID_SENTENCE_LENGTHS else "varied"
+ voice.words_to_avoid = data.get("words_to_avoid", []) or []
+ voice.sample_paragraphs = data.get("sample_paragraphs", []) or []
+ voice.do_notes = data.get("do_notes", []) or []
+ voice.dont_notes = data.get("dont_notes", []) or []
+ voice.raw_response = raw
+ voice.mark_distilled()
+ voice.save()
+ return voice
+
+
+# ---------------------------------------------------------------------------
+# Content generation
+# ---------------------------------------------------------------------------
+BRIEF_SYSTEM = """You are a social content creator. From the brief, produce one \
+ready-to-post piece of content.
+
+Return ONLY valid JSON with exactly these keys:
+{
+ "topic": "a short topic/title",
+ "hook": "a scroll-stopping opening line",
+ "caption": "the main caption/body",
+ "call_to_action": "a single clear call to action",
+ "hashtags": "a space-separated set of relevant hashtags"
+}
+Return only the JSON, no preamble."""
+
+
+def _apply_voice_to_system(system: str) -> str:
+ return system + PromptEnhancer.voice_block(VoiceProfile.objects.filter(pk=1).first())
+
+
+def generate_from_brief(brief: str, content_type: str = "", temperature: float = 0.7) -> dict:
+ """One structured call producing all ContentItem text fields from a brief."""
+ system = _apply_voice_to_system(BRIEF_SYSTEM)
+ raw = ai_client.generate(system, f"Brief:\n\n{brief}", temperature)
+ return _extract_json(raw)
+
+
+def generate_from_template(template, brief: str = "", temperature: float = 0.7) -> dict:
+ """Run a template's prompts in order, feeding each result forward, and
+ return a dict of {ContentItem field: generated value}."""
+ voice = VoiceProfile.objects.filter(pk=1).first()
+ content_type = PromptEnhancer.category_for(template.content_type)
+ keywords = f"\n\nKeywords to weave in: {template.keywords}" if template.keywords else ""
+
+ results: dict[str, str] = {}
+ context_bits = []
+ if brief:
+ context_bits.append(f"Brief: {brief}")
+
+ for step in template.prompts.all():
+ instruction = f"{step.prompt}{keywords}"
+ if brief and not context_bits:
+ instruction = f"Brief: {brief}\n\n{instruction}"
+ system, user = PromptEnhancer.build(
+ instruction, content_type=content_type,
+ context="\n".join(context_bits), voice=voice,
+ )
+ value = (ai_client.generate(system, user, temperature) or "").strip()
+ results[step.target_field] = value
+ context_bits.append(f"{step.get_target_field_display()}: {value}")
+
+ return results
+
+
+def generate_content_item(*, template=None, brief: str = "", scheduled_date=None,
+ project=None, content_type: str = "",
+ temperature: float = 0.7) -> ContentItem:
+ """Create a draft ContentItem (approval_status='ready') from a template or
+ a free-text brief, using whichever AI provider is active."""
+ fields = {
+ "topic": "", "hook": "", "caption": "", "call_to_action": "", "hashtags": "",
+ }
+
+ if template is not None:
+ fields.update(generate_from_template(template, brief=brief, temperature=temperature))
+ item_content_type = template.content_type or content_type
+ else:
+ fields.update(generate_from_brief(brief, content_type=content_type, temperature=temperature))
+ item_content_type = content_type
+
+ item = ContentItem.objects.create(
+ project=project,
+ scheduled_date=scheduled_date,
+ content_type=item_content_type or "",
+ topic=fields.get("topic", "")[:200],
+ hook=fields.get("hook", ""),
+ caption=fields.get("caption", ""),
+ call_to_action=fields.get("call_to_action", "")[:255],
+ hashtags=fields.get("hashtags", ""),
+ status="draft",
+ approval_status="ready",
+ )
+ if template is not None:
+ platforms = list(template.default_platforms.all())
+ if platforms:
+ item.platforms.set(platforms)
+ return item
diff --git a/content_calendar/migrations/0001_initial.py b/content_calendar/migrations/0001_initial.py
new file mode 100644
index 0000000..0c7d873
--- /dev/null
+++ b/content_calendar/migrations/0001_initial.py
@@ -0,0 +1,55 @@
+# Generated by Django 5.2.9 on 2026-07-30 10:05
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('projects', '0008_alter_task_recurrence'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Platform',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=50, unique=True)),
+ ('order', models.IntegerField(default=0, help_text='Sort order in pickers/lists')),
+ ],
+ options={
+ 'ordering': ['order', 'name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ContentItem',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('scheduled_date', models.DateField(blank=True, help_text='The day this content is planned to go out', null=True)),
+ ('pillar', models.CharField(blank=True, help_text='Content pillar / theme (free text)', max_length=100)),
+ ('content_type', models.CharField(blank=True, choices=[('reel', 'Reel / Short'), ('carousel', 'Carousel'), ('single_image', 'Single image'), ('story', 'Story'), ('video', 'Video'), ('live', 'Live'), ('text', 'Text / Thread')], max_length=20)),
+ ('topic', models.CharField(blank=True, max_length=200)),
+ ('hook', models.TextField(blank=True, help_text='Opening line / scroll-stopper')),
+ ('caption', models.TextField(blank=True)),
+ ('call_to_action', models.CharField(blank=True, max_length=255)),
+ ('hashtags', models.TextField(blank=True)),
+ ('tags_links', models.TextField(blank=True, help_text='Accounts to tag, links to include')),
+ ('audio_sound', models.CharField(blank=True, help_text='Audio/sound to use', max_length=255)),
+ ('image_video_cover', models.FileField(blank=True, help_text='Cover image or video file', null=True, upload_to='content_calendar/')),
+ ('content_link', models.URLField(blank=True, help_text='Published URL, once live')),
+ ('status', models.CharField(choices=[('idea', 'Idea'), ('draft', 'Draft'), ('scheduled', 'Scheduled'), ('published', 'Published'), ('archived', 'Archived')], default='idea', max_length=20)),
+ ('approval_status', models.CharField(choices=[('draft', 'Draft'), ('ready', 'Ready for Approval'), ('approved', 'Approved'), ('changes', 'Needs Changes')], default='draft', max_length=20)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('order', models.IntegerField(default=0)),
+ ('project', models.ForeignKey(blank=True, help_text='Optional: the business/project this content is for', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='content_items', to='projects.project')),
+ ('platforms', models.ManyToManyField(blank=True, related_name='content_items', to='content_calendar.platform')),
+ ],
+ options={
+ 'ordering': ['order', '-created_at'],
+ },
+ ),
+ ]
diff --git a/content_calendar/migrations/0002_seed_platforms.py b/content_calendar/migrations/0002_seed_platforms.py
new file mode 100644
index 0000000..988da0a
--- /dev/null
+++ b/content_calendar/migrations/0002_seed_platforms.py
@@ -0,0 +1,32 @@
+# content_calendar/migrations/0002_seed_platforms.py
+from django.db import migrations
+
+PLATFORMS = [
+ ("TikTok", 1),
+ ("Instagram", 2),
+ ("Pinterest", 3),
+ ("Facebook", 4),
+ ("YouTube", 5),
+ ("WhatsApp", 6),
+]
+
+
+def seed_platforms(apps, schema_editor):
+ Platform = apps.get_model("content_calendar", "Platform")
+ for name, order in PLATFORMS:
+ Platform.objects.get_or_create(name=name, defaults={"order": order})
+
+
+def unseed_platforms(apps, schema_editor):
+ Platform = apps.get_model("content_calendar", "Platform")
+ Platform.objects.filter(name__in=[p[0] for p in PLATFORMS]).delete()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("content_calendar", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.RunPython(seed_platforms, unseed_platforms),
+ ]
diff --git a/content_calendar/migrations/0003_voiceprofile_contenttemplate_contenttemplateprompt.py b/content_calendar/migrations/0003_voiceprofile_contenttemplate_contenttemplateprompt.py
new file mode 100644
index 0000000..28321d4
--- /dev/null
+++ b/content_calendar/migrations/0003_voiceprofile_contenttemplate_contenttemplateprompt.py
@@ -0,0 +1,61 @@
+# Generated by Django 5.2.9 on 2026-07-30 10:22
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('content_calendar', '0002_seed_platforms'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VoiceProfile',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('summary', models.TextField(blank=True, default='', help_text='One or two sentence summary of the voice')),
+ ('tone_words', models.JSONField(blank=True, default=list, help_text='List of tone/adjective words')),
+ ('sentence_length', models.CharField(choices=[('short', 'Short & punchy'), ('medium', 'Medium'), ('long', 'Long & flowing'), ('varied', 'Varied')], default='varied', max_length=10)),
+ ('words_to_avoid', models.JSONField(blank=True, default=list, help_text='Words/phrases this voice never uses')),
+ ('sample_paragraphs', models.JSONField(blank=True, default=list, help_text='2-3 distilled sample paragraphs')),
+ ('do_notes', models.JSONField(blank=True, default=list, help_text="Do's for writing in this voice")),
+ ('dont_notes', models.JSONField(blank=True, default=list, help_text="Don'ts for writing in this voice")),
+ ('raw_response', models.TextField(blank=True, default='', help_text='Raw JSON returned by the model')),
+ ('enabled', models.BooleanField(default=True, help_text='Apply this voice to generated content')),
+ ('distilled_at', models.DateTimeField(blank=True, null=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ContentTemplate',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100)),
+ ('description', models.TextField(blank=True)),
+ ('content_type', models.CharField(blank=True, choices=[('reel', 'Reel / Short'), ('carousel', 'Carousel'), ('single_image', 'Single image'), ('story', 'Story'), ('video', 'Video'), ('live', 'Live'), ('text', 'Text / Thread')], help_text="Sets the generated item's content type", max_length=20)),
+ ('keywords', models.TextField(blank=True, help_text='Comma-separated keywords to steer generation')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('default_platforms', models.ManyToManyField(blank=True, help_text='Platforms pre-set on items generated from this template', related_name='content_templates', to='content_calendar.platform')),
+ ],
+ options={
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ContentTemplatePrompt',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=255)),
+ ('target_field', models.CharField(choices=[('topic', 'Topic'), ('hook', 'Hook'), ('caption', 'Caption'), ('call_to_action', 'Call to action'), ('hashtags', 'Hashtags')], default='caption', max_length=20)),
+ ('prompt', models.TextField(help_text="Instruction for this step, e.g. 'Write a scroll-stopping hook.'")),
+ ('order', models.IntegerField(default=0)),
+ ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prompts', to='content_calendar.contenttemplate')),
+ ],
+ options={
+ 'ordering': ['order'],
+ },
+ ),
+ ]
diff --git a/content_calendar/migrations/__init__.py b/content_calendar/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/content_calendar/models.py b/content_calendar/models.py
new file mode 100644
index 0000000..0233a66
--- /dev/null
+++ b/content_calendar/models.py
@@ -0,0 +1,220 @@
+# content_calendar/models.py
+from django.db import models
+from django.utils import timezone
+
+from projects.models import Project
+
+
+class Platform(models.Model):
+ """A social platform a content item can be published to.
+
+ Kept as its own table (rather than six booleans on ContentItem) so adding a
+ new platform later is a data change, not a migration. Seeded with the common
+ ones in the initial data migration.
+ """
+
+ name = models.CharField(max_length=50, unique=True)
+ order = models.IntegerField(default=0, help_text="Sort order in pickers/lists")
+
+ class Meta:
+ ordering = ["order", "name"]
+
+ def __str__(self):
+ return self.name
+
+
+class ContentItem(models.Model):
+ """One piece of scheduled content — mirrors the columns of the content
+ planning spreadsheet, plus a workflow/approval status so items can move from
+ idea → published and be reviewed before they go out.
+ """
+
+ # Where an item sits in the production pipeline.
+ STATUS_CHOICES = [
+ ("idea", "Idea"),
+ ("draft", "Draft"),
+ ("scheduled", "Scheduled"),
+ ("published", "Published"),
+ ("archived", "Archived"),
+ ]
+
+ # The review gate. The generation engine (Phase 3) creates items as "ready".
+ APPROVAL_STATUS_CHOICES = [
+ ("draft", "Draft"),
+ ("ready", "Ready for Approval"),
+ ("approved", "Approved"),
+ ("changes", "Needs Changes"),
+ ]
+
+ # Common post formats. Blank allowed so it never blocks entering an item.
+ CONTENT_TYPE_CHOICES = [
+ ("reel", "Reel / Short"),
+ ("carousel", "Carousel"),
+ ("single_image", "Single image"),
+ ("story", "Story"),
+ ("video", "Video"),
+ ("live", "Live"),
+ ("text", "Text / Thread"),
+ ]
+
+ # Optional link to a business/project, so a content item can be tied to one
+ # of several things you track. Null = general content.
+ project = models.ForeignKey(
+ Project,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="content_items",
+ help_text="Optional: the business/project this content is for",
+ )
+
+ scheduled_date = models.DateField(
+ null=True, blank=True, help_text="The day this content is planned to go out"
+ )
+ pillar = models.CharField(
+ max_length=100, blank=True, help_text="Content pillar / theme (free text)"
+ )
+ content_type = models.CharField(
+ max_length=20, choices=CONTENT_TYPE_CHOICES, blank=True
+ )
+ topic = models.CharField(max_length=200, blank=True)
+
+ platforms = models.ManyToManyField(
+ Platform, blank=True, related_name="content_items"
+ )
+
+ hook = models.TextField(blank=True, help_text="Opening line / scroll-stopper")
+ caption = models.TextField(blank=True)
+ call_to_action = models.CharField(max_length=255, blank=True)
+ hashtags = models.TextField(blank=True)
+ tags_links = models.TextField(blank=True, help_text="Accounts to tag, links to include")
+ audio_sound = models.CharField(max_length=255, blank=True, help_text="Audio/sound to use")
+ image_video_cover = models.FileField(
+ upload_to="content_calendar/", null=True, blank=True,
+ help_text="Cover image or video file",
+ )
+ content_link = models.URLField(blank=True, help_text="Published URL, once live")
+
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="idea")
+ approval_status = models.CharField(
+ max_length=20, choices=APPROVAL_STATUS_CHOICES, default="draft"
+ )
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+ order = models.IntegerField(default=0)
+
+ class Meta:
+ ordering = ["order", "-created_at"]
+
+ def __str__(self):
+ return self.topic or self.hook[:50] or f"Content #{self.pk}"
+
+ @property
+ def is_overdue(self):
+ return (
+ self.scheduled_date is not None
+ and self.status not in ("published", "archived")
+ and self.scheduled_date < timezone.localdate()
+ )
+
+
+# ---------------------------------------------------------------------------
+# Generation engine (Phase 3) — ported/adapted from ai-marketing
+# ---------------------------------------------------------------------------
+class VoiceProfile(models.Model):
+ """A structured 'brand voice' distilled from writing samples via one AI call.
+
+ Single-user, so this is a singleton (get via `VoiceProfile.get_solo()`).
+ When enabled and distilled, the generation engine layers it into the system
+ prompt so generated content sounds like the owner.
+ Ported from ai-marketing's content_generation.VoiceProfile.
+ """
+
+ SENTENCE_LENGTH_CHOICES = [
+ ("short", "Short & punchy"),
+ ("medium", "Medium"),
+ ("long", "Long & flowing"),
+ ("varied", "Varied"),
+ ]
+
+ summary = models.TextField(blank=True, default="", help_text="One or two sentence summary of the voice")
+ tone_words = models.JSONField(default=list, blank=True, help_text="List of tone/adjective words")
+ sentence_length = models.CharField(max_length=10, choices=SENTENCE_LENGTH_CHOICES, default="varied")
+ words_to_avoid = models.JSONField(default=list, blank=True, help_text="Words/phrases this voice never uses")
+ sample_paragraphs = models.JSONField(default=list, blank=True, help_text="2-3 distilled sample paragraphs")
+ do_notes = models.JSONField(default=list, blank=True, help_text="Do's for writing in this voice")
+ dont_notes = models.JSONField(default=list, blank=True, help_text="Don'ts for writing in this voice")
+ raw_response = models.TextField(blank=True, default="", help_text="Raw JSON returned by the model")
+ enabled = models.BooleanField(default=True, help_text="Apply this voice to generated content")
+ distilled_at = models.DateTimeField(null=True, blank=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ def __str__(self):
+ return "Voice profile"
+
+ @classmethod
+ def get_solo(cls):
+ obj, _ = cls.objects.get_or_create(pk=1)
+ return obj
+
+ @property
+ def is_distilled(self):
+ return self.distilled_at is not None
+
+ def mark_distilled(self):
+ self.distilled_at = timezone.now()
+
+
+class ContentTemplate(models.Model):
+ """A named recipe (e.g. 'Instagram carousel') — an ordered set of prompts
+ that produce values for a ContentItem's fields in one run.
+ Adapted from ai-marketing's content_templates.Template.
+ """
+
+ name = models.CharField(max_length=100)
+ description = models.TextField(blank=True)
+ content_type = models.CharField(
+ max_length=20, choices=ContentItem.CONTENT_TYPE_CHOICES, blank=True,
+ help_text="Sets the generated item's content type",
+ )
+ default_platforms = models.ManyToManyField(
+ Platform, blank=True, related_name="content_templates",
+ help_text="Platforms pre-set on items generated from this template",
+ )
+ keywords = models.TextField(blank=True, help_text="Comma-separated keywords to steer generation")
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ["name"]
+
+ def __str__(self):
+ return self.name
+
+
+class ContentTemplatePrompt(models.Model):
+ """One ordered step of a ContentTemplate, producing one ContentItem field.
+ Adapted from ai-marketing's content_templates.TemplatePrompt.
+ """
+
+ # Must match attribute names on ContentItem so results map straight across.
+ TARGET_FIELD_CHOICES = [
+ ("topic", "Topic"),
+ ("hook", "Hook"),
+ ("caption", "Caption"),
+ ("call_to_action", "Call to action"),
+ ("hashtags", "Hashtags"),
+ ]
+
+ template = models.ForeignKey(ContentTemplate, on_delete=models.CASCADE, related_name="prompts")
+ name = models.CharField(max_length=255)
+ target_field = models.CharField(max_length=20, choices=TARGET_FIELD_CHOICES, default="caption")
+ prompt = models.TextField(help_text="Instruction for this step, e.g. 'Write a scroll-stopping hook.'")
+ order = models.IntegerField(default=0)
+
+ class Meta:
+ ordering = ["order"]
+
+ def __str__(self):
+ return f"{self.template.name} · {self.name}"
diff --git a/content_calendar/serializers.py b/content_calendar/serializers.py
new file mode 100644
index 0000000..887a001
--- /dev/null
+++ b/content_calendar/serializers.py
@@ -0,0 +1,21 @@
+# content_calendar/serializers.py
+from rest_framework import serializers
+
+from .models import ContentItem, Platform
+
+
+class PlatformSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Platform
+ fields = ["id", "name", "order"]
+
+
+class ContentItemSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ContentItem
+ fields = [
+ "id", "project", "scheduled_date", "pillar", "content_type", "topic",
+ "platforms", "hook", "caption", "call_to_action", "hashtags",
+ "tags_links", "audio_sound", "image_video_cover", "content_link",
+ "status", "approval_status", "created_at", "updated_at", "order",
+ ]
diff --git a/content_calendar/templates/content_calendar/content/_approval_badge.html b/content_calendar/templates/content_calendar/content/_approval_badge.html
new file mode 100644
index 0000000..d933189
--- /dev/null
+++ b/content_calendar/templates/content_calendar/content/_approval_badge.html
@@ -0,0 +1,8 @@
+{% comment %}Approval status pill. Expects `status` (draft|ready|approved|changes).{% endcomment %}
+
+ {% if status == 'draft' %}Draft{% elif status == 'ready' %}Ready for Approval{% elif status == 'approved' %}Approved{% elif status == 'changes' %}Needs Changes{% endif %}
+
diff --git a/content_calendar/templates/content_calendar/content/_toolbar.html b/content_calendar/templates/content_calendar/content/_toolbar.html
new file mode 100644
index 0000000..c733bc7
--- /dev/null
+++ b/content_calendar/templates/content_calendar/content/_toolbar.html
@@ -0,0 +1,40 @@
+{% comment %}Shared toolbar for the content views. Expects: view, projects, current_project_id.{% endcomment %}
+
+
+
diff --git a/content_calendar/templates/content_calendar/content/board.html b/content_calendar/templates/content_calendar/content/board.html
new file mode 100644
index 0000000..444a3e1
--- /dev/null
+++ b/content_calendar/templates/content_calendar/content/board.html
@@ -0,0 +1,63 @@
+{% extends 'base.html' %}
+{% block title %}Content board - {{ site_config.site_name }}{% endblock %}
+
+{% block content %}
+
+
Content
+ {% include "content_calendar/content/_toolbar.html" %}
+
+
+ {% for col in columns %}
+
+
+ {{ col.label }}
+ {{ col.items|length }}
+
+
+ {% for item in col.items %}
+
+
{{ item }}
+
+ {% if item.project %}{{ item.project.name }}{% else %}—{% endif %}
+ {% if item.scheduled_date %}
+ {{ item.scheduled_date|date:"M d" }}
+ {% endif %}
+
+
+ {% for value, label in approval_choices %}
+ {{ label }}
+ {% endfor %}
+
+
+ {% empty %}
+
Nothing here.
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/content_calendar/templates/content_calendar/content/calendar.html b/content_calendar/templates/content_calendar/content/calendar.html
new file mode 100644
index 0000000..e214bfb
--- /dev/null
+++ b/content_calendar/templates/content_calendar/content/calendar.html
@@ -0,0 +1,54 @@
+{% extends 'base.html' %}
+{% block title %}Content calendar - {{ site_config.site_name }}{% endblock %}
+
+{% block content %}
+
+
Content
+ {% include "content_calendar/content/_toolbar.html" %}
+
+
+
+
+
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
Sun
+
+ {% for week in weeks %}
+
+ {% for day in week %}
+
+
+
{{ day.date|date:"j" }}
+
+
+
+
+ {% for item in day.items %}
+
{{ item }}
+ {% endfor %}
+
+
+ {% endfor %}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/content_calendar/templates/content_calendar/content/content_confirm_delete.html b/content_calendar/templates/content_calendar/content/content_confirm_delete.html
new file mode 100644
index 0000000..6be37ec
--- /dev/null
+++ b/content_calendar/templates/content_calendar/content/content_confirm_delete.html
@@ -0,0 +1,18 @@
+{% extends 'base.html' %}
+{% block title %}Delete content - {{ site_config.site_name }}{% endblock %}
+
+{% block content %}
+
+
+
Delete content?
+
This will permanently delete “{{ object }}”. This can't be undone.
+
+ {% csrf_token %}
+
+
+
+
+{% endblock %}
diff --git a/content_calendar/templates/content_calendar/content/content_form.html b/content_calendar/templates/content_calendar/content/content_form.html
new file mode 100644
index 0000000..49f6c21
--- /dev/null
+++ b/content_calendar/templates/content_calendar/content/content_form.html
@@ -0,0 +1,42 @@
+{% extends 'base.html' %}
+{% block title %}{% if editing %}Edit content{% else %}New content{% endif %} - {{ site_config.site_name }}{% endblock %}
+
+{% block content %}
+
+
+
+ {% if request.GET.generated %}
+
+ Draft generated and set to “Ready for Approval”. Review and edit below, then save.
+
+ {% endif %}
+
+
+
{% if editing %}Edit content{% else %}New content{% endif %}
+
+
+ {% csrf_token %}
+ {% if form.non_field_errors %}{{ form.non_field_errors }}
{% endif %}
+
+ {% for field in form %}
+
+
{{ field.label }}
+ {{ field }}
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %}
+ {% if field.errors %}
{{ field.errors|join:", " }}
{% endif %}
+
+ {% endfor %}
+
+
+
Cancel
+
{% if editing %}Save changes{% else %}Add content{% endif %}
+
+
+
+
+{% endblock %}
diff --git a/content_calendar/templates/content_calendar/content/generate.html b/content_calendar/templates/content_calendar/content/generate.html
new file mode 100644
index 0000000..f65a84a
--- /dev/null
+++ b/content_calendar/templates/content_calendar/content/generate.html
@@ -0,0 +1,37 @@
+{% extends 'base.html' %}
+{% block title %}Generate content - {{ site_config.site_name }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
Generate content
+
Pick a template or write a brief, choose a date, and we'll draft a content item for review using your active AI provider.
+
+
+ {% csrf_token %}
+ {% if form.non_field_errors %}
+ {{ form.non_field_errors }}
+ {% endif %}
+
+ {% for field in form %}
+
+
{{ field.label }}
+ {{ field }}
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %}
+ {% if field.errors %}
{{ field.errors|join:", " }}
{% endif %}
+
+ {% endfor %}
+
+
+
+
+
+{% endblock %}
diff --git a/content_calendar/templates/content_calendar/content/table.html b/content_calendar/templates/content_calendar/content/table.html
new file mode 100644
index 0000000..e207610
--- /dev/null
+++ b/content_calendar/templates/content_calendar/content/table.html
@@ -0,0 +1,50 @@
+{% extends 'base.html' %}
+{% block title %}Content - {{ site_config.site_name }}{% endblock %}
+
+{% block content %}
+
+
Content
+ {% include "content_calendar/content/_toolbar.html" %}
+
+
+
+
+
+ Scheduled
+ Topic
+ Type
+ Platforms
+ Status
+ Approval
+
+
+
+
+ {% for item in items %}
+
+
+ {% if item.scheduled_date %}{{ item.scheduled_date|date:"M d, Y" }}{% else %}— {% endif %}
+
+
+ {{ item }}
+
+ {{ item.get_content_type_display|default:"—" }}
+
+ {% for p in item.platforms.all %}{{ p.name }}{% if not forloop.last %}, {% endif %}{% empty %}— {% endfor %}
+
+ {{ item.get_status_display }}
+
+ {% include "content_calendar/content/_approval_badge.html" with status=item.approval_status %}
+
+
+ Edit
+
+
+ {% empty %}
+ No content{% if current_project %} for {{ current_project.name }}{% endif %} yet.
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/content_calendar/templates/content_calendar/content/voice_profile.html b/content_calendar/templates/content_calendar/content/voice_profile.html
new file mode 100644
index 0000000..f29facc
--- /dev/null
+++ b/content_calendar/templates/content_calendar/content/voice_profile.html
@@ -0,0 +1,57 @@
+{% extends 'base.html' %}
+{% block title %}Voice profile - {{ site_config.site_name }}{% endblock %}
+
+{% block content %}
+
+
+
+
Voice profile
+
Paste some of your own writing and we'll distil it into a reusable voice, so generated content sounds like you.
+
+ {% if distilled %}
+
Voice profile updated.
+ {% endif %}
+
+ {% if voice.is_distilled %}
+
+
+
Current voice
+
+ {% csrf_token %}
+
+
+ {% if voice.enabled %}● Enabled (click to disable){% else %}○ Disabled (click to enable){% endif %}
+
+
+
+ {% if voice.summary %}
{{ voice.summary }}
{% endif %}
+ {% if voice.tone_words %}
Tone: {{ voice.tone_words|join:", " }}
{% endif %}
+
Sentence length: {{ voice.get_sentence_length_display }}
+ {% if voice.words_to_avoid %}
Avoids: {{ voice.words_to_avoid|join:", " }}
{% endif %}
+
+ {% endif %}
+
+
+
{% if voice.is_distilled %}Re-distil from new samples{% else %}Distil your voice{% endif %}
+
+ {% csrf_token %}
+ {% if form.non_field_errors %}
+ {{ form.non_field_errors }}
+ {% endif %}
+ {% for field in form %}
+
+
{{ field.label }}
+ {{ field }}
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %}
+ {% if field.errors %}
{{ field.errors|join:", " }}
{% endif %}
+
+ {% endfor %}
+
+ Distil voice
+
+
+
+
+{% endblock %}
diff --git a/content_calendar/tests.py b/content_calendar/tests.py
new file mode 100644
index 0000000..fa6c220
--- /dev/null
+++ b/content_calendar/tests.py
@@ -0,0 +1,210 @@
+# content_calendar/tests.py
+import json
+from datetime import date
+from unittest.mock import patch
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+
+from . import generation
+from .models import (
+ ContentItem,
+ ContentTemplate,
+ ContentTemplatePrompt,
+ Platform,
+ VoiceProfile,
+)
+
+
+class ContentCalendarTests(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ User = get_user_model()
+ cls.user = User.objects.create_user(username="tester", password="pw12345")
+ # Instagram is already seeded by the data migration; reuse it.
+ cls.instagram = Platform.objects.get(name="Instagram")
+
+ def setUp(self):
+ self.client.force_login(self.user)
+
+ def test_platforms_seeded_by_migration(self):
+ # The data migration ships the common platforms out of the box.
+ self.assertTrue(Platform.objects.filter(name="TikTok").exists())
+ self.assertTrue(Platform.objects.filter(name="YouTube").exists())
+
+ def test_create_item_shows_on_calendar(self):
+ item = ContentItem.objects.create(
+ topic="Summer skincare tips",
+ scheduled_date=date(2026, 8, 15),
+ status="scheduled",
+ )
+ item.platforms.add(self.instagram)
+
+ resp = self.client.get(reverse("content_calendar:content_calendar") + "?month=2026-08")
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "Summer skincare tips")
+
+ def test_board_groups_by_approval_status(self):
+ ContentItem.objects.create(topic="Ready one", approval_status="ready")
+ ContentItem.objects.create(topic="Draft one", approval_status="draft")
+
+ resp = self.client.get(reverse("content_calendar:content_board"))
+ self.assertEqual(resp.status_code, 200)
+ columns = {c["key"]: c for c in resp.context["columns"]}
+ self.assertEqual(len(columns["ready"]["items"]), 1)
+ self.assertEqual(len(columns["draft"]["items"]), 1)
+
+ def test_set_approval_updates_item(self):
+ item = ContentItem.objects.create(topic="Needs review", approval_status="ready")
+ resp = self.client.post(
+ reverse("content_calendar:content_set_approval", args=[item.pk]),
+ data={"status": "approved"},
+ )
+ self.assertEqual(resp.status_code, 200)
+ item.refresh_from_db()
+ self.assertEqual(item.approval_status, "approved")
+
+ def test_set_approval_rejects_invalid(self):
+ item = ContentItem.objects.create(topic="X", approval_status="ready")
+ resp = self.client.post(
+ reverse("content_calendar:content_set_approval", args=[item.pk]),
+ data={"status": "bogus"},
+ )
+ self.assertEqual(resp.status_code, 400)
+ item.refresh_from_db()
+ self.assertEqual(item.approval_status, "ready")
+
+ def test_login_required(self):
+ self.client.logout()
+ resp = self.client.get(reverse("content_calendar:content_table"))
+ self.assertEqual(resp.status_code, 302)
+
+
+class ExtractJsonTests(TestCase):
+ def test_plain_json(self):
+ self.assertEqual(generation._extract_json('{"a": 1}'), {"a": 1})
+
+ def test_fenced_json(self):
+ raw = "Here you go:\n```json\n{\"a\": 2}\n```\nthanks"
+ self.assertEqual(generation._extract_json(raw), {"a": 2})
+
+ def test_embedded_json(self):
+ self.assertEqual(generation._extract_json('noise {"a": 3} more'), {"a": 3})
+
+ def test_garbage_returns_empty(self):
+ self.assertEqual(generation._extract_json("no json here"), {})
+
+
+class PromptEnhancerTests(TestCase):
+ def test_build_returns_system_and_user(self):
+ system, user = generation.PromptEnhancer.build("write a hook", content_type="social")
+ self.assertIn("social media", system.lower())
+ self.assertIn("write a hook", user)
+
+ def test_voice_block_included_when_enabled(self):
+ voice = VoiceProfile.get_solo()
+ voice.summary = "warm and direct"
+ voice.tone_words = ["friendly", "bold"]
+ voice.mark_distilled()
+ voice.save()
+ block = generation.PromptEnhancer.voice_block(voice)
+ self.assertIn("warm and direct", block)
+ self.assertIn("friendly", block)
+
+ def test_voice_block_empty_when_not_distilled(self):
+ voice = VoiceProfile.get_solo() # not distilled
+ self.assertEqual(generation.PromptEnhancer.voice_block(voice), "")
+
+
+class DistillVoiceTests(TestCase):
+ @patch("content_calendar.generation.ai_client.generate")
+ def test_distill_saves_singleton(self, mock_generate):
+ mock_generate.return_value = json.dumps({
+ "summary": "punchy and warm",
+ "tone_words": ["bold", "warm"],
+ "sentence_length": "short",
+ "words_to_avoid": ["synergy"],
+ "sample_paragraphs": ["Hello there."],
+ "do_notes": ["be direct"],
+ "dont_notes": ["don't ramble"],
+ })
+ voice = generation.distill_voice("some writing samples")
+ self.assertEqual(voice.pk, 1)
+ self.assertEqual(voice.summary, "punchy and warm")
+ self.assertEqual(voice.sentence_length, "short")
+ self.assertTrue(voice.is_distilled)
+ self.assertEqual(VoiceProfile.objects.count(), 1)
+
+ @patch("content_calendar.generation.ai_client.generate")
+ def test_distill_defaults_bad_sentence_length(self, mock_generate):
+ mock_generate.return_value = json.dumps({"sentence_length": "epic"})
+ voice = generation.distill_voice("x")
+ self.assertEqual(voice.sentence_length, "varied")
+
+
+class GenerationTests(TestCase):
+ @patch("content_calendar.generation.ai_client.generate")
+ def test_generate_from_brief_creates_ready_draft(self, mock_generate):
+ mock_generate.return_value = json.dumps({
+ "topic": "Summer skincare",
+ "hook": "Melting in the heat?",
+ "caption": "Here's how to keep skin fresh.",
+ "call_to_action": "Save this post.",
+ "hashtags": "#skincare #summer",
+ })
+ item = generation.generate_content_item(brief="summer skincare", content_type="social")
+ self.assertEqual(item.approval_status, "ready")
+ self.assertEqual(item.status, "draft")
+ self.assertEqual(item.topic, "Summer skincare")
+ self.assertEqual(item.hook, "Melting in the heat?")
+
+ @patch("content_calendar.generation.ai_client.generate")
+ def test_generate_from_template_runs_prompts_in_order(self, mock_generate):
+ mock_generate.side_effect = ["A hook", "A caption", "A CTA"]
+ template = ContentTemplate.objects.create(name="IG Post", content_type="reel")
+ ContentTemplatePrompt.objects.create(template=template, name="Hook", target_field="hook", prompt="hook", order=1)
+ ContentTemplatePrompt.objects.create(template=template, name="Caption", target_field="caption", prompt="caption", order=2)
+ ContentTemplatePrompt.objects.create(template=template, name="CTA", target_field="call_to_action", prompt="cta", order=3)
+
+ item = generation.generate_content_item(template=template, brief="a brief")
+ self.assertEqual(item.hook, "A hook")
+ self.assertEqual(item.caption, "A caption")
+ self.assertEqual(item.call_to_action, "A CTA")
+ self.assertEqual(item.content_type, "reel")
+ self.assertEqual(mock_generate.call_count, 3)
+
+
+class GenerateViewTests(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ User = get_user_model()
+ cls.user = User.objects.create_user(username="tester", password="pw12345")
+
+ def setUp(self):
+ self.client.force_login(self.user)
+
+ def test_generate_requires_template_or_brief(self):
+ resp = self.client.post(reverse("content_calendar:generate"), data={"brief": ""})
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "Choose a template or write a brief")
+
+ @patch("content_calendar.generation.ai_client.generate")
+ def test_generate_creates_item_and_redirects(self, mock_generate):
+ mock_generate.return_value = json.dumps({
+ "topic": "T", "hook": "H", "caption": "C",
+ "call_to_action": "CTA", "hashtags": "#h",
+ })
+ resp = self.client.post(reverse("content_calendar:generate"),
+ data={"brief": "make something", "content_type": "reel"})
+ self.assertEqual(resp.status_code, 302)
+ item = ContentItem.objects.latest("created_at")
+ self.assertEqual(item.approval_status, "ready")
+ self.assertIn(f"/content/{item.pk}/edit/", resp.url)
+
+ def test_generate_reports_missing_provider(self):
+ # No active AIProviderConfig → AIConfigError surfaced on the form.
+ resp = self.client.post(reverse("content_calendar:generate"),
+ data={"brief": "make something"})
+ self.assertEqual(resp.status_code, 200)
+ self.assertContains(resp, "No active AI provider")
diff --git a/content_calendar/urls.py b/content_calendar/urls.py
new file mode 100644
index 0000000..d08e089
--- /dev/null
+++ b/content_calendar/urls.py
@@ -0,0 +1,28 @@
+# content_calendar/urls.py
+from django.urls import include, path
+from rest_framework.routers import DefaultRouter
+
+from . import views
+
+app_name = "content_calendar"
+
+router = DefaultRouter()
+router.register(r"content", views.ContentItemViewSet)
+router.register(r"platforms", views.PlatformViewSet)
+
+urlpatterns = [
+ # Content views (Table / Board / Calendar)
+ path("", views.ContentCalendarView.as_view(), name="content_calendar"),
+ path("table/", views.ContentTableView.as_view(), name="content_table"),
+ path("board/", views.ContentBoardView.as_view(), name="content_board"),
+ path("calendar/", views.ContentCalendarView.as_view(), name="content_calendar_alias"),
+ path("new/", views.ContentItemCreateView.as_view(), name="content_create"),
+ path("generate/", views.GenerateView.as_view(), name="generate"),
+ path("voice/", views.VoiceProfileView.as_view(), name="voice_profile"),
+ path("/edit/", views.ContentItemUpdateView.as_view(), name="content_edit"),
+ path("/delete/", views.ContentItemDeleteView.as_view(), name="content_delete"),
+ path("/set-approval/", views.ContentItemSetApprovalView.as_view(), name="content_set_approval"),
+
+ # API URLs
+ path("api/", include(router.urls)),
+]
diff --git a/content_calendar/views.py b/content_calendar/views.py
new file mode 100644
index 0000000..f20be1a
--- /dev/null
+++ b/content_calendar/views.py
@@ -0,0 +1,268 @@
+# content_calendar/views.py
+import calendar
+import json
+from collections import defaultdict
+from datetime import date, timedelta
+
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.http import HttpResponseRedirect, JsonResponse
+from django.shortcuts import get_object_or_404
+from django.urls import reverse, reverse_lazy
+from django.utils import timezone
+from django.views.generic import CreateView, DeleteView, TemplateView, UpdateView, View
+from rest_framework import viewsets
+
+from django.shortcuts import redirect, render
+
+from ai_settings import ai_client
+
+from projects.models import Project
+
+from . import generation
+from .forms import ContentItemForm, GenerateForm, VoiceProfileForm
+from .models import ContentItem, Platform, VoiceProfile
+from .serializers import ContentItemSerializer, PlatformSerializer
+
+
+# ---------------------------------------------------------------------------
+# API views
+# ---------------------------------------------------------------------------
+class ContentItemViewSet(viewsets.ModelViewSet):
+ queryset = ContentItem.objects.all()
+ serializer_class = ContentItemSerializer
+
+
+class PlatformViewSet(viewsets.ModelViewSet):
+ queryset = Platform.objects.all()
+ serializer_class = PlatformSerializer
+
+
+# ---------------------------------------------------------------------------
+# Content views: Table / Board / Calendar — all filter by ?project=
+# (cloned from projects.views._TaskViewMixin and friends)
+# ---------------------------------------------------------------------------
+class _ContentViewMixin(LoginRequiredMixin):
+ def get_current_project(self):
+ pid = self.request.GET.get("project")
+ if pid and pid.isdigit():
+ return Project.objects.filter(pk=pid).first()
+ return None
+
+ def get_items(self):
+ qs = ContentItem.objects.select_related("project").prefetch_related("platforms")
+ project = self.get_current_project()
+ if project:
+ qs = qs.filter(project=project)
+ return qs
+
+ def base_context(self, view_name):
+ project = self.get_current_project()
+ return {
+ "projects": Project.objects.all().order_by("name"),
+ "current_project": project,
+ "current_project_id": str(project.id) if project else "",
+ "view": view_name,
+ }
+
+
+class ContentTableView(_ContentViewMixin, TemplateView):
+ template_name = "content_calendar/content/table.html"
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx.update(self.base_context("table"))
+ items = self.get_items().order_by("scheduled_date", "order")
+ ctx["items"] = items
+ ctx["status_choices"] = ContentItem.STATUS_CHOICES
+ ctx["approval_choices"] = ContentItem.APPROVAL_STATUS_CHOICES
+ return ctx
+
+
+class ContentBoardView(_ContentViewMixin, TemplateView):
+ """Board grouped by approval status, so items can be reviewed/approved here."""
+
+ template_name = "content_calendar/content/board.html"
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx.update(self.base_context("board"))
+ items = list(self.get_items().order_by("scheduled_date", "order"))
+ ctx["columns"] = [
+ {"key": key, "label": label,
+ "items": [i for i in items if i.approval_status == key]}
+ for key, label in ContentItem.APPROVAL_STATUS_CHOICES
+ ]
+ ctx["approval_choices"] = ContentItem.APPROVAL_STATUS_CHOICES
+ return ctx
+
+
+class ContentCalendarView(_ContentViewMixin, TemplateView):
+ template_name = "content_calendar/content/calendar.html"
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx.update(self.base_context("calendar"))
+ today = timezone.localdate()
+
+ try:
+ year, month = map(int, self.request.GET.get("month", "").split("-"))
+ first = date(year, month, 1)
+ except (ValueError, AttributeError):
+ first = today.replace(day=1)
+
+ last_day = calendar.monthrange(first.year, first.month)[1]
+ month_end = first.replace(day=last_day)
+
+ by_day = defaultdict(list)
+ for item in self.get_items().filter(scheduled_date__range=(first, month_end)):
+ by_day[item.scheduled_date].append(item)
+
+ cal = calendar.Calendar(firstweekday=0) # Monday
+ weeks = []
+ for week in cal.monthdatescalendar(first.year, first.month):
+ days = []
+ for d in week:
+ days.append({
+ "date": d,
+ "in_month": d.month == first.month,
+ "is_today": d == today,
+ "items": by_day.get(d, []),
+ })
+ weeks.append(days)
+
+ ctx["weeks"] = weeks
+ ctx["month_label"] = first.strftime("%B %Y")
+ ctx["prev_month"] = (first - timedelta(days=1)).replace(day=1).strftime("%Y-%m")
+ ctx["next_month"] = (month_end + timedelta(days=1)).strftime("%Y-%m")
+ return ctx
+
+
+# ---------------------------------------------------------------------------
+# Create / edit / delete (UI CRUD alongside the admin)
+# ---------------------------------------------------------------------------
+class ContentItemCreateView(LoginRequiredMixin, CreateView):
+ model = ContentItem
+ form_class = ContentItemForm
+ template_name = "content_calendar/content/content_form.html"
+ success_url = reverse_lazy("content_calendar:content_calendar")
+
+ def get_initial(self):
+ initial = super().get_initial()
+ d = self.request.GET.get("date")
+ if d:
+ initial["scheduled_date"] = d
+ pid = self.request.GET.get("project")
+ if pid and pid.isdigit():
+ initial["project"] = pid
+ return initial
+
+
+class ContentItemUpdateView(LoginRequiredMixin, UpdateView):
+ model = ContentItem
+ form_class = ContentItemForm
+ template_name = "content_calendar/content/content_form.html"
+
+ def get_success_url(self):
+ return reverse("content_calendar:content_table")
+
+ def get_context_data(self, **kwargs):
+ ctx = super().get_context_data(**kwargs)
+ ctx["editing"] = True
+ return ctx
+
+
+class ContentItemDeleteView(LoginRequiredMixin, DeleteView):
+ model = ContentItem
+ template_name = "content_calendar/content/content_confirm_delete.html"
+ success_url = reverse_lazy("content_calendar:content_table")
+
+
+class ContentItemSetApprovalView(LoginRequiredMixin, View):
+ """Set an item's approval status (for the board select). JSON or form 'status'."""
+
+ def post(self, request, *args, **kwargs):
+ item = get_object_or_404(ContentItem, pk=kwargs["pk"])
+ status = request.POST.get("status")
+ if status is None:
+ try:
+ status = json.loads(request.body or "{}").get("status")
+ except json.JSONDecodeError:
+ status = None
+ if status in dict(ContentItem.APPROVAL_STATUS_CHOICES):
+ item.approval_status = status
+ item.save(update_fields=["approval_status", "updated_at"])
+ return JsonResponse({"approval_status": item.approval_status})
+ return JsonResponse({"error": "invalid status"}, status=400)
+
+
+# ---------------------------------------------------------------------------
+# Generation engine (Phase 3): Generate panel + Voice profile
+# ---------------------------------------------------------------------------
+class GenerateView(LoginRequiredMixin, View):
+ template_name = "content_calendar/content/generate.html"
+
+ def get(self, request, *args, **kwargs):
+ initial = {}
+ if request.GET.get("date"):
+ initial["scheduled_date"] = request.GET["date"]
+ pid = request.GET.get("project")
+ if pid and pid.isdigit():
+ initial["project"] = pid
+ return render(request, self.template_name, {"form": GenerateForm(initial=initial)})
+
+ def post(self, request, *args, **kwargs):
+ form = GenerateForm(request.POST)
+ if not form.is_valid():
+ return render(request, self.template_name, {"form": form})
+
+ cd = form.cleaned_data
+ try:
+ item = generation.generate_content_item(
+ template=cd.get("template"),
+ brief=cd.get("brief", "").strip(),
+ scheduled_date=cd.get("scheduled_date"),
+ project=cd.get("project"),
+ content_type=cd.get("content_type", ""),
+ )
+ except ai_client.AIConfigError as exc:
+ form.add_error(None, f"{exc} Set one up in AI Settings.")
+ return render(request, self.template_name, {"form": form})
+ except Exception as exc: # noqa: BLE001 — surface SDK/network errors to the user
+ form.add_error(None, f"Generation failed: {type(exc).__name__}: {exc}")
+ return render(request, self.template_name, {"form": form})
+
+ return redirect(reverse("content_calendar:content_edit", args=[item.pk]) + "?generated=1")
+
+
+class VoiceProfileView(LoginRequiredMixin, View):
+ template_name = "content_calendar/content/voice_profile.html"
+
+ def get(self, request, *args, **kwargs):
+ return render(request, self.template_name, {
+ "form": VoiceProfileForm(),
+ "voice": VoiceProfile.get_solo(),
+ })
+
+ def post(self, request, *args, **kwargs):
+ # Toggle enable/disable without re-distilling.
+ if request.POST.get("action") == "toggle":
+ voice = VoiceProfile.get_solo()
+ voice.enabled = not voice.enabled
+ voice.save(update_fields=["enabled", "updated_at"])
+ return redirect(reverse("content_calendar:voice_profile"))
+
+ form = VoiceProfileForm(request.POST)
+ if not form.is_valid():
+ return render(request, self.template_name, {"form": form, "voice": VoiceProfile.get_solo()})
+
+ try:
+ voice = generation.distill_voice(form.cleaned_data["sample_text"])
+ except ai_client.AIConfigError as exc:
+ form.add_error(None, f"{exc} Set one up in AI Settings.")
+ return render(request, self.template_name, {"form": form, "voice": VoiceProfile.get_solo()})
+ except Exception as exc: # noqa: BLE001
+ form.add_error(None, f"Distillation failed: {type(exc).__name__}: {exc}")
+ return render(request, self.template_name, {"form": form, "voice": VoiceProfile.get_solo()})
+
+ return render(request, self.template_name,
+ {"form": VoiceProfileForm(), "voice": voice, "distilled": True})
diff --git a/desktop.py b/desktop.py
index b075bec..28c8840 100644
--- a/desktop.py
+++ b/desktop.py
@@ -65,6 +65,38 @@ def _resource_path(rel: str) -> str:
return str(Path(base) / rel)
+def _maybe_connect_claude(data_dir: Path) -> bool:
+ """On first launch, register this install with Claude Desktop (if present).
+
+ Runs at most once successfully — a marker file stops it re-adding an entry
+ the owner may have deliberately removed. If Claude Desktop isn't installed
+ yet, we don't write the marker, so a later install still gets picked up.
+ Returns True only when a connection was just made/updated. Never raises.
+ """
+ try:
+ marker = data_dir / ".claude_connected"
+ if marker.exists():
+ return False
+
+ from mcp_server.desktop_connect import connect
+
+ frozen = getattr(sys, "frozen", False)
+ base_dir = Path(__file__).resolve().parent
+ status = connect(base_dir, frozen)
+
+ if status in ("connected", "updated", "unchanged"):
+ try:
+ data_dir.mkdir(parents=True, exist_ok=True)
+ marker.write_text(status, encoding="utf-8")
+ except Exception:
+ pass
+ return status in ("connected", "updated")
+ # 'no-claude' or 'error': leave the marker off so we can retry next time.
+ return False
+ except Exception:
+ return False
+
+
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
@@ -124,6 +156,11 @@ def main() -> None:
except Exception as exc: # pragma: no cover
print(f"Could not seed example content: {exc}")
+ # Offer one-click Claude Desktop connection: register this install as an MCP
+ # server so the owner can drive the calendar from Claude. Returns True if it
+ # was just connected (so we can show a "restart Claude Desktop" note).
+ just_connected = _maybe_connect_claude(data_dir)
+
# --- Start the web server in a background thread ---
from waitress import serve
from config.wsgi import application
@@ -131,6 +168,9 @@ def main() -> None:
port = _find_free_port()
host = "127.0.0.1"
url = f"http://{host}:{port}/"
+ # Show the one-time "restart Claude Desktop to finish" banner via a URL flag
+ # that clears itself as soon as the user navigates anywhere.
+ initial_url = url + "?claude=connected" if just_connected else url
server_thread = threading.Thread(
target=lambda: serve(application, host=host, port=port, threads=8),
@@ -147,7 +187,7 @@ def main() -> None:
webview.create_window(
"Project Tracker",
- url,
+ initial_url,
width=1280,
height=860,
min_size=(900, 600),
diff --git a/mcp_launcher.py b/mcp_launcher.py
new file mode 100644
index 0000000..44bfd0f
--- /dev/null
+++ b/mcp_launcher.py
@@ -0,0 +1,42 @@
+"""Console entry point for the content-calendar MCP server (stdio).
+
+Used two ways:
+ - Packaged: PyInstaller builds this as ProjectTracker-mcp.exe (a *console*
+ executable — a windowed .exe has no stdin/stdout, which stdio MCP needs).
+ Claude Desktop launches it. See project_tracker.spec.
+ - Dev: python mcp_launcher.py (equivalent to `python manage.py runmcp`)
+
+It reuses the desktop launcher's data-dir + SECRET_KEY handling so the MCP
+server reads the SAME database and settings as the app window.
+
+stdout is reserved for the MCP protocol — nothing here may print to it.
+"""
+import os
+import sys
+from pathlib import Path
+
+
+def main() -> None:
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
+ os.environ.setdefault("DEBUG", "True")
+
+ # Reuse the packaged app's persisted SECRET_KEY / writable data dir so this
+ # process points at the same install. Safe no-op in a normal dev checkout.
+ try:
+ from desktop import _ensure_secret_key, _writable_data_dir
+
+ _ensure_secret_key(_writable_data_dir())
+ except Exception:
+ pass
+
+ import django
+
+ django.setup()
+
+ from mcp_server.server import mcp
+
+ mcp.run(transport="stdio")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mcp_server/README.md b/mcp_server/README.md
new file mode 100644
index 0000000..1f8705a
--- /dev/null
+++ b/mcp_server/README.md
@@ -0,0 +1,94 @@
+# Content Calendar MCP Server
+
+This lets you manage your content calendar directly from **Claude Desktop**,
+**Cowork**, or **Claude Code** — list this week's content, create items, move
+dates, change status, and generate drafts — without opening the app's UI.
+
+It's the same tools the in-app Assistant uses, exposed over MCP's **stdio**
+transport. Because it's a single-user local install, there's nothing to host and
+no auth token to manage: Claude launches the server as a subprocess on demand.
+
+## Requirements
+
+- **The Claude Desktop app, installed and signed in.** This connector works with
+ Claude Desktop (it launches the server on your own machine). It does **not**
+ work from claude.ai in a web browser or on a phone — that would need a
+ publicly-hosted HTTPS version. Any Claude plan that supports MCP/custom
+ connectors will do.
+- This project installed and working (its virtualenv, its SQLite database).
+- The Python dependencies installed (`pip install -r requirements.txt`), which
+ now include `mcp`.
+
+## Does this cost money? (API key vs. your Claude subscription)
+
+Managing your calendar through Claude Desktop runs on **your Claude
+subscription — no API key, no per-token cost.** The tools that list, create,
+move, edit and schedule content are just database operations.
+
+- **Free (no API key):** everything driven from Claude Desktop, *including
+ writing new content* — you ask Claude to write the captions and it saves them
+ with `create_content_item`. Claude does the writing on your subscription.
+- **Uses your API key (costs extra):** only the two buttons *inside the app* —
+ the **✨ Generate** panel and the **Assistant** chat page — plus the
+ `generate_draft` / `distill_voice` MCP tools, which call the provider directly.
+ Set a key up in **AI Settings** only if you want those. You can ignore them
+ entirely and let Claude Desktop do the writing for free.
+
+## Run it manually (to check it starts)
+
+```bash
+python manage.py runmcp
+```
+
+It will print `Starting content-calendar MCP server (stdio)…` to stderr and then
+wait for a client on stdin/stdout. Press Ctrl-C to stop. You normally don't run
+it yourself — Claude starts it for you using the config below.
+
+## Connect it to Claude
+
+Add one entry pointing at **this install**. Use the full path to the project's
+Python (the virtualenv) and set `cwd` to the project root.
+
+### Claude Desktop / Cowork
+
+Edit `claude_desktop_config.json` (Settings → Developer → Edit Config) and add:
+
+```json
+{
+ "mcpServers": {
+ "content-calendar": {
+ "command": "C:\\Users\\you\\path\\to\\tracker\\trackervenv\\Scripts\\python.exe",
+ "args": ["manage.py", "runmcp"],
+ "cwd": "C:\\Users\\you\\path\\to\\tracker"
+ }
+ }
+}
+```
+
+On macOS/Linux the `command` is `.../venv/bin/python` and paths use `/`.
+Restart Claude Desktop; "content-calendar" appears in the tools menu.
+
+### Claude Code
+
+From the project directory:
+
+```bash
+claude mcp add content-calendar -- ./trackervenv/Scripts/python.exe manage.py runmcp
+```
+
+(Windows PowerShell: use the full path to `python.exe`.)
+
+## Tools
+
+| Tool | What it does | Example prompt |
+|---|---|---|
+| `list_content_items` | List items, optionally by date range / status | "What content do I have scheduled this week?" |
+| `get_calendar_month` | List everything scheduled in a month | "Show me everything planned for August 2026." |
+| `create_content_item` | Add a content item | "Add a Reel for Aug 12 titled 'Morning routine'." |
+| `update_status` | Set pipeline status or approval status | "Approve item 5." / "Mark item 5 as published." |
+| `reschedule_content_item` | Move an item to another date | "Move item 3 to next Friday." |
+| `generate_draft` | Draft an item from a brief and/or template | "Draft an Instagram post about summer skincare for Aug 20." |
+| `distill_voice` | Update the reusable voice profile from samples | "Here are three of my posts — learn my voice: …" |
+
+Dates are `YYYY-MM-DD`. `generate_draft` and `distill_voice` use whichever AI
+provider is active in AI Settings.
diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/mcp_server/desktop_connect.py b/mcp_server/desktop_connect.py
new file mode 100644
index 0000000..6b8adc5
--- /dev/null
+++ b/mcp_server/desktop_connect.py
@@ -0,0 +1,87 @@
+# mcp_server/desktop_connect.py
+"""Register this install as an MCP server in the user's Claude Desktop config.
+
+Lets a fresh install connect to Claude Desktop automatically on first launch,
+so a non-technical owner never has to hand-edit a JSON file. Everything here is
+best-effort and must never raise into the app's startup path — callers get a
+short status string instead.
+
+There is no "account" to connect to: an MCP server is just a local config entry
+telling Claude Desktop which program to launch. So this is safe and reversible —
+the owner can remove the entry in Claude Desktop at any time.
+"""
+import json
+import os
+import sys
+from pathlib import Path
+
+SERVER_NAME = "content-calendar"
+
+
+def claude_config_path() -> Path | None:
+ """Location of claude_desktop_config.json for this OS, or None if unknown."""
+ if sys.platform == "win32":
+ base = os.environ.get("APPDATA")
+ return Path(base) / "Claude" / "claude_desktop_config.json" if base else None
+ if sys.platform == "darwin":
+ return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
+ # Linux (community Claude Desktop builds) — best effort.
+ return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
+
+
+def claude_desktop_present(config_path: Path | None = None) -> bool:
+ """True if Claude Desktop looks installed (its config directory exists)."""
+ path = config_path or claude_config_path()
+ return bool(path and path.parent.exists())
+
+
+def server_entry(base_dir: Path, frozen: bool) -> dict:
+ """The mcpServers entry to install — correct for a packaged .exe vs dev."""
+ if frozen:
+ # Packaged: point at the sibling console MCP executable that
+ # project_tracker.spec builds next to the main app .exe.
+ folder = Path(sys.executable).resolve().parent
+ exe_name = "ProjectTracker-mcp.exe" if sys.platform == "win32" else "ProjectTracker-mcp"
+ return {"command": str(folder / exe_name), "args": [], "cwd": str(folder)}
+ # Dev: use the current interpreter to run the management command.
+ return {"command": sys.executable, "args": ["manage.py", "runmcp"], "cwd": str(base_dir)}
+
+
+def connect(base_dir: Path, frozen: bool, *, config_path: Path | None = None,
+ name: str = SERVER_NAME) -> str:
+ """Merge the server entry into Claude Desktop's config.
+
+ Returns one of: 'connected' (newly added), 'updated' (changed to match this
+ install), 'unchanged' (already correct), 'no-claude' (Claude Desktop not
+ found), or 'error: '. Never raises. Preserves all other config keys,
+ and refuses to overwrite a config file it can't parse.
+ """
+ try:
+ cfg_path = config_path or claude_config_path()
+ if not cfg_path or not cfg_path.parent.exists():
+ return "no-claude"
+
+ cfg = {}
+ if cfg_path.exists():
+ try:
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8")) or {}
+ except (json.JSONDecodeError, OSError):
+ # Don't clobber a file we can't understand.
+ return "error: existing Claude config could not be read"
+ if not isinstance(cfg, dict):
+ return "error: existing Claude config is not an object"
+
+ servers = cfg.setdefault("mcpServers", {})
+ if not isinstance(servers, dict):
+ return "error: existing mcpServers is not an object"
+
+ desired = server_entry(base_dir, frozen)
+ if servers.get(name) == desired:
+ return "unchanged"
+
+ status = "updated" if name in servers else "connected"
+ servers[name] = desired
+ cfg_path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
+ return status
+ except Exception as exc: # noqa: BLE001 — must never break app startup
+ return f"error: {exc}"
diff --git a/mcp_server/server.py b/mcp_server/server.py
new file mode 100644
index 0000000..03767e6
--- /dev/null
+++ b/mcp_server/server.py
@@ -0,0 +1,122 @@
+# mcp_server/server.py
+"""MCP server exposing the content-calendar tools over stdio.
+
+This is a second transport for the exact same tool functions the chat assistant
+uses (ai_assistant.tools) — no new business logic. Point Claude Desktop / Cowork
+/ Claude Code at it (see mcp_server/README.md) to manage the calendar directly.
+
+Run it with: python manage.py runmcp (stdio transport)
+"""
+import os
+
+import django
+from django.apps import apps
+
+# FastMCP invokes sync tools inside its asyncio loop, which trips Django's
+# "cannot call this from an async context" ORM guard. This is a single-user,
+# local stdio server that handles one request at a time, so briefly running a
+# SQLite query on the loop thread is safe — opt out of the guard rather than
+# thread every ORM call. Must be set before any ORM use.
+os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "1")
+
+# Works both as a standalone script (`python -m mcp_server.server`) and when
+# imported after Django is already configured (the runmcp management command).
+if not apps.ready:
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
+ django.setup()
+
+from mcp.server.fastmcp import FastMCP
+
+from ai_assistant import tools
+
+INSTRUCTIONS = (
+ "Manage a personal content calendar: list, create, schedule, review and "
+ "generate social content. Dates are ISO strings (YYYY-MM-DD)."
+)
+
+mcp = FastMCP("content-calendar", instructions=INSTRUCTIONS)
+
+
+@mcp.tool()
+def create_content_item(
+ topic: str,
+ scheduled_date: str = "",
+ content_type: str = "",
+ pillar: str = "",
+ hook: str = "",
+ caption: str = "",
+ call_to_action: str = "",
+ hashtags: str = "",
+ status: str = "idea",
+ approval_status: str = "draft",
+ project_id: int | None = None,
+ platforms: list[str] | None = None,
+) -> dict:
+ """Create a new content item. `topic` is required; dates are YYYY-MM-DD.
+ content_type is one of reel/carousel/single_image/story/video/live/text;
+ status is idea/draft/scheduled/published/archived; approval_status is
+ draft/ready/approved/changes."""
+ return tools.create_content_item(
+ topic=topic, scheduled_date=scheduled_date or None, content_type=content_type,
+ pillar=pillar, hook=hook, caption=caption, call_to_action=call_to_action,
+ hashtags=hashtags, status=status, approval_status=approval_status,
+ project_id=project_id, platforms=platforms,
+ )
+
+
+@mcp.tool()
+def list_content_items(
+ start_date: str = "",
+ end_date: str = "",
+ status: str = "",
+ approval_status: str = "",
+) -> dict:
+ """List content items, optionally filtered by scheduled-date range and/or
+ status/approval_status. Dates are YYYY-MM-DD."""
+ return tools.list_content_items(
+ start_date=start_date or None, end_date=end_date or None,
+ status=status or None, approval_status=approval_status or None,
+ )
+
+
+@mcp.tool()
+def update_status(item_id: int, status: str) -> dict:
+ """Set a content item's status. Accepts a pipeline status
+ (idea/draft/scheduled/published/archived) or an approval status
+ (draft/ready/approved/changes)."""
+ return tools.update_status(item_id, status)
+
+
+@mcp.tool()
+def reschedule_content_item(item_id: int, scheduled_date: str = "") -> dict:
+ """Move a content item to a different date (YYYY-MM-DD), or clear it when
+ scheduled_date is empty."""
+ return tools.reschedule_content_item(item_id, scheduled_date or None)
+
+
+@mcp.tool()
+def generate_draft(brief: str = "", template_id: int | None = None, date: str = "") -> dict:
+ """Generate a draft content item from a brief and/or a content template,
+ using the active AI provider. Returns the created item (approval 'ready')."""
+ return tools.generate_draft(brief=brief, template_id=template_id, date=date or None)
+
+
+@mcp.tool()
+def distill_voice(samples: str) -> dict:
+ """Distil writing samples into the reusable voice profile used by the
+ generator."""
+ return tools.distill_voice(samples)
+
+
+@mcp.tool()
+def get_calendar_month(year: int, month: int) -> dict:
+ """List all content scheduled in a given month (month is 1-12)."""
+ return tools.get_calendar_month(year, month)
+
+
+def main():
+ mcp.run(transport="stdio")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mcp_server/tests.py b/mcp_server/tests.py
new file mode 100644
index 0000000..1629afa
--- /dev/null
+++ b/mcp_server/tests.py
@@ -0,0 +1,112 @@
+# mcp_server/tests.py
+import asyncio
+import json
+import tempfile
+from pathlib import Path
+
+from django.test import SimpleTestCase, TestCase
+
+from content_calendar.models import ContentItem
+
+from . import desktop_connect, server
+
+
+class McpServerTests(TestCase):
+ def test_all_tools_registered(self):
+ listed = asyncio.run(server.mcp.list_tools())
+ names = {t.name for t in listed}
+ self.assertEqual(names, {
+ "create_content_item", "list_content_items", "update_status",
+ "reschedule_content_item", "generate_draft", "distill_voice",
+ "get_calendar_month",
+ })
+
+ def test_create_content_item_required_topic_in_schema(self):
+ listed = asyncio.run(server.mcp.list_tools())
+ create = next(t for t in listed if t.name == "create_content_item")
+ self.assertEqual(create.inputSchema.get("required"), ["topic"])
+
+ def test_wrapper_create_and_list(self):
+ # @mcp.tool() returns the original function, so the wrappers are
+ # directly callable and exercise the same tools + ORM the assistant uses.
+ out = server.create_content_item(topic="Via MCP", scheduled_date="2026-08-12")
+ self.assertEqual(out["topic"], "Via MCP")
+ self.assertEqual(out["scheduled_date"], "2026-08-12")
+
+ listed = server.list_content_items(start_date="2026-08-01", end_date="2026-08-31")
+ self.assertEqual(listed["count"], 1)
+
+ def test_wrapper_update_status(self):
+ item = ContentItem.objects.create(topic="X")
+ server.update_status(item.id, "published")
+ item.refresh_from_db()
+ self.assertEqual(item.status, "published")
+
+ def test_wrapper_get_calendar_month(self):
+ from datetime import date
+ ContentItem.objects.create(topic="Aug", scheduled_date=date(2026, 8, 3))
+ out = server.get_calendar_month(2026, 8)
+ self.assertEqual(out["count"], 1)
+
+
+class DesktopConnectTests(SimpleTestCase):
+ """The Claude Desktop auto-connect config merge. No DB needed."""
+
+ def _cfg(self, tmp):
+ d = Path(tmp) / "Claude"
+ d.mkdir()
+ return d / "claude_desktop_config.json"
+
+ def test_connects_when_config_dir_exists(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ cfg = self._cfg(tmp)
+ status = desktop_connect.connect(Path("/proj"), frozen=False, config_path=cfg)
+ self.assertEqual(status, "connected")
+ data = json.loads(cfg.read_text())
+ self.assertIn("content-calendar", data["mcpServers"])
+
+ def test_idempotent_second_run(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ cfg = self._cfg(tmp)
+ desktop_connect.connect(Path("/proj"), frozen=False, config_path=cfg)
+ status = desktop_connect.connect(Path("/proj"), frozen=False, config_path=cfg)
+ self.assertEqual(status, "unchanged")
+
+ def test_preserves_other_keys_and_servers(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ cfg = self._cfg(tmp)
+ cfg.write_text(json.dumps({
+ "coworkUserFilesPath": "C:/x",
+ "mcpServers": {"other": {"command": "x"}},
+ }))
+ desktop_connect.connect(Path("/proj"), frozen=False, config_path=cfg)
+ data = json.loads(cfg.read_text())
+ self.assertEqual(data["coworkUserFilesPath"], "C:/x")
+ self.assertIn("other", data["mcpServers"])
+ self.assertIn("content-calendar", data["mcpServers"])
+
+ def test_no_claude_when_dir_missing(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ cfg = Path(tmp) / "Claude" / "claude_desktop_config.json" # dir not created
+ status = desktop_connect.connect(Path("/proj"), frozen=False, config_path=cfg)
+ self.assertEqual(status, "no-claude")
+
+ def test_refuses_to_clobber_unreadable_config(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ cfg = self._cfg(tmp)
+ cfg.write_text("{ this is not json ]")
+ status = desktop_connect.connect(Path("/proj"), frozen=False, config_path=cfg)
+ self.assertTrue(status.startswith("error"))
+ # Original content left untouched.
+ self.assertEqual(cfg.read_text(), "{ this is not json ]")
+
+ def test_dev_entry_uses_manage_py(self):
+ entry = desktop_connect.server_entry(Path("/proj"), frozen=False)
+ self.assertEqual(entry["args"], ["manage.py", "runmcp"])
+ self.assertEqual(entry["cwd"], str(Path("/proj")))
+
+ def test_frozen_entry_points_at_mcp_exe(self):
+ entry = desktop_connect.server_entry(Path("/proj"), frozen=True)
+ self.assertTrue(entry["command"].endswith("ProjectTracker-mcp.exe")
+ or entry["command"].endswith("ProjectTracker-mcp"))
+ self.assertEqual(entry["args"], [])
diff --git a/pages/templates/pages/_sidebar.html b/pages/templates/pages/_sidebar.html
index 7996ec3..d12b9b1 100644
--- a/pages/templates/pages/_sidebar.html
+++ b/pages/templates/pages/_sidebar.html
@@ -5,10 +5,13 @@
Dashboard
Projects
Tasks
+ Content
+ Assistant
CRM
Assets
Products
Settings
+ AI Settings
{% if sidebar_favorites %}
diff --git a/project_tracker.spec b/project_tracker.spec
index 480d9a2..c27e246 100644
--- a/project_tracker.spec
+++ b/project_tracker.spec
@@ -6,11 +6,17 @@ Build from the project root, with the virtual environment active:
pyinstaller project_tracker.spec
-Output: dist/ProjectTracker/ProjectTracker.exe (a one-folder app -- ship the
-whole ProjectTracker folder)
+Output: dist/ProjectTracker/ with TWO executables:
+ - ProjectTracker.exe the app window (windowed)
+ - ProjectTracker-mcp.exe the MCP server for Claude Desktop (console — stdio
+ needs real stdin/stdout, which a windowed exe lacks)
+
+Ship the whole ProjectTracker folder. Claude Desktop is pointed at
+ProjectTracker-mcp.exe automatically on first launch (see desktop.py /
+mcp_server/desktop_connect.py).
"""
-from PyInstaller.utils.hooks import collect_all, collect_submodules
+from PyInstaller.utils.hooks import collect_all, collect_data_files, collect_submodules
datas = []
binaries = []
@@ -29,16 +35,27 @@ for pkg in [
"webview",
"anthropic",
"openai",
- "google.generativeai",
+ "google.genai", # new Google GenAI SDK (replaces google-generativeai)
]:
p_datas, p_binaries, p_hidden = collect_all(pkg)
datas += p_datas
binaries += p_binaries
hiddenimports += p_hidden
+# MCP SDK: collect only the server/client/shared subpackages, NOT mcp.cli.
+# mcp.cli imports the optional 'typer' dependency and calls sys.exit(1) at
+# import time when it's missing, which crashes a whole-package collect_all.
+for sub in ["mcp.server", "mcp.client", "mcp.shared"]:
+ hiddenimports += collect_submodules(sub)
+hiddenimports += ["mcp", "mcp.types"]
+datas += collect_data_files("mcp")
+
# Local Django apps + the project package. Django imports these by name at
# runtime, so PyInstaller can't discover them by following imports alone.
-for pkg in ["config", "core", "crm", "pages", "projects", "assets", "products", "sequences"]:
+for pkg in [
+ "config", "core", "crm", "pages", "projects", "assets", "products",
+ "sequences", "content_calendar", "ai_settings", "ai_assistant", "mcp_server",
+]:
hiddenimports += collect_submodules(pkg)
# Project-level templates and static source files.
@@ -51,7 +68,10 @@ datas += [
# expects /templates//*.html on disk). collect_submodules() only
# grabs .py files, so these non-Python assets have to be listed explicitly
# or the packaged app 500s with TemplateDoesNotExist.
-for app in ["core", "crm", "pages", "projects", "assets", "products"]:
+for app in [
+ "core", "crm", "pages", "projects", "assets", "products",
+ "content_calendar", "ai_settings", "ai_assistant",
+]:
datas += [(f"{app}/templates", f"{app}/templates")]
# core/templatetags is a package but also gets used via {% load %} in
@@ -92,6 +112,27 @@ hiddenimports += [
"products.views",
"products.apps",
"sequences.apps",
+ # --- Content OS apps (content calendar + AI settings/assistant + MCP) ---
+ "content_calendar.apps",
+ "content_calendar.urls",
+ "content_calendar.views",
+ "content_calendar.serializers",
+ "content_calendar.forms",
+ "content_calendar.generation",
+ "content_calendar.models",
+ "ai_settings.apps",
+ "ai_settings.urls",
+ "ai_settings.views",
+ "ai_settings.ai_client",
+ "ai_settings.encryption",
+ "ai_settings.models",
+ "ai_assistant.apps",
+ "ai_assistant.urls",
+ "ai_assistant.views",
+ "ai_assistant.agent",
+ "ai_assistant.tools",
+ "mcp_server.server",
+ "mcp_server.desktop_connect",
"anthropic",
]
@@ -105,7 +146,8 @@ hiddenimports += [
]
-a = Analysis(
+# --- Analysis 1: the app window (entry point desktop.py) -------------------
+a_app = Analysis(
["desktop.py"],
pathex=[],
binaries=binaries,
@@ -117,12 +159,10 @@ a = Analysis(
excludes=[],
noarchive=False,
)
-
-pyz = PYZ(a.pure)
-
-exe = EXE(
- pyz,
- a.scripts,
+pyz_app = PYZ(a_app.pure)
+exe_app = EXE(
+ pyz_app,
+ a_app.scripts,
[],
exclude_binaries=True,
name="ProjectTracker",
@@ -139,10 +179,50 @@ exe = EXE(
icon="static/images/favicon.ico",
)
+# --- Analysis 2: the MCP server (entry point mcp_launcher.py) ---------------
+# Console subsystem: MCP stdio needs real stdin/stdout, which a windowed exe
+# does not have. Claude Desktop launches this with piped std handles.
+a_mcp = Analysis(
+ ["mcp_launcher.py"],
+ pathex=[],
+ binaries=binaries,
+ datas=datas,
+ hiddenimports=hiddenimports,
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[],
+ noarchive=False,
+)
+pyz_mcp = PYZ(a_mcp.pure)
+exe_mcp = EXE(
+ pyz_mcp,
+ a_mcp.scripts,
+ [],
+ exclude_binaries=True,
+ name="ProjectTracker-mcp",
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ console=True,
+ disable_windowed_traceback=False,
+ argv_emulation=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+ icon="static/images/favicon.ico",
+)
+
+# Collect both executables and their (deduplicated) dependencies into one
+# shippable folder.
coll = COLLECT(
- exe,
- a.binaries,
- a.datas,
+ exe_app,
+ exe_mcp,
+ a_app.binaries,
+ a_app.datas,
+ a_mcp.binaries,
+ a_mcp.datas,
strip=False,
upx=True,
upx_exclude=[],
diff --git a/requirements.txt b/requirements.txt
index 3a671e6..f79edc8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,7 +21,8 @@ urllib3
whitenoise
anthropic
openai
-google-generativeai
+google-genai
+mcp<2
# Desktop app (native window + production server for the bundled .exe)
waitress
diff --git a/templates/base.html b/templates/base.html
index a25dd27..143f1c3 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -48,6 +48,14 @@
Site is currently in maintenance mode. Some features may be unavailable.
{% endif %}
+
+
+ {% if request.GET.claude == 'connected' %}
+
+ ✅ Connected to Claude Desktop . Fully quit and reopen Claude Desktop once to finish — then ask it “What content do I have scheduled this week?”
+
+ {% endif %}
@@ -63,10 +71,13 @@