From 9fa53916076ac99ce3a5a3bfdb706330a7e70e3b Mon Sep 17 00:00:00 2001 From: Diane Corriette <131492009+todiane@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:10:41 +0100 Subject: [PATCH 1/8] Phase 1: content_calendar app (ContentItem + calendar/board/list) Add a new content_calendar app that mirrors the content-planning spreadsheet as a ContentItem model, with calendar/board/table views cloned from the existing Task views. - ContentItem: scheduled_date, pillar, content_type, topic, M2M to a small Platform model, hook/caption/CTA/hashtags/tags_links/audio, image_video_cover file, content_link, status + approval_status, and an optional FK to projects.Project. - Platform seeded (TikTok/Instagram/Pinterest/Facebook/YouTube/WhatsApp) via data migration so it's populated on install. - Calendar (month grid), board (grouped by approval status for review), and table views + UI create/edit/delete, all LoginRequiredMixin. - DRF ContentItem/Platform viewsets, full admin CRUD, nav links. - Tests for calendar render, board grouping, and approval updates. Co-Authored-By: Claude Opus 4.8 --- config/settings.py | 1 + config/urls.py | 1 + content_calendar/__init__.py | 0 content_calendar/admin.py | 35 ++++ content_calendar/apps.py | 7 + content_calendar/forms.py | 45 +++++ content_calendar/migrations/0001_initial.py | 55 +++++ .../migrations/0002_seed_platforms.py | 32 +++ content_calendar/migrations/__init__.py | 0 content_calendar/models.py | 120 +++++++++++ content_calendar/serializers.py | 21 ++ .../content/_approval_badge.html | 8 + .../content_calendar/content/_toolbar.html | 38 ++++ .../content_calendar/content/board.html | 63 ++++++ .../content_calendar/content/calendar.html | 54 +++++ .../content/content_confirm_delete.html | 18 ++ .../content/content_form.html | 36 ++++ .../content_calendar/content/table.html | 50 +++++ content_calendar/tests.py | 72 +++++++ content_calendar/urls.py | 26 +++ content_calendar/views.py | 190 ++++++++++++++++++ pages/templates/pages/_sidebar.html | 1 + templates/base.html | 1 + 23 files changed, 874 insertions(+) create mode 100644 content_calendar/__init__.py create mode 100644 content_calendar/admin.py create mode 100644 content_calendar/apps.py create mode 100644 content_calendar/forms.py create mode 100644 content_calendar/migrations/0001_initial.py create mode 100644 content_calendar/migrations/0002_seed_platforms.py create mode 100644 content_calendar/migrations/__init__.py create mode 100644 content_calendar/models.py create mode 100644 content_calendar/serializers.py create mode 100644 content_calendar/templates/content_calendar/content/_approval_badge.html create mode 100644 content_calendar/templates/content_calendar/content/_toolbar.html create mode 100644 content_calendar/templates/content_calendar/content/board.html create mode 100644 content_calendar/templates/content_calendar/content/calendar.html create mode 100644 content_calendar/templates/content_calendar/content/content_confirm_delete.html create mode 100644 content_calendar/templates/content_calendar/content/content_form.html create mode 100644 content_calendar/templates/content_calendar/content/table.html create mode 100644 content_calendar/tests.py create mode 100644 content_calendar/urls.py create mode 100644 content_calendar/views.py diff --git a/config/settings.py b/config/settings.py index 9fcfb55..833f28d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -75,6 +75,7 @@ "assets", "products", "sequences", + "content_calendar", ] MIDDLEWARE = [ diff --git a/config/urls.py b/config/urls.py index 3846a53..05f01a7 100644 --- a/config/urls.py +++ b/config/urls.py @@ -28,6 +28,7 @@ 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")), # 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..90bc728 --- /dev/null +++ b/content_calendar/admin.py @@ -0,0 +1,35 @@ +# content_calendar/admin.py +from django.contrib import admin + +from .models import ContentItem, Platform + + +@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"), + }), + ) 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..99799e9 --- /dev/null +++ b/content_calendar/forms.py @@ -0,0 +1,45 @@ +# content_calendar/forms.py +from django import forms + +from .models import ContentItem + +_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() 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/__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..0e7de09 --- /dev/null +++ b/content_calendar/models.py @@ -0,0 +1,120 @@ +# content_calendar/models.py +from django.db import models + +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): + from django.utils import timezone + + return ( + self.scheduled_date is not None + and self.status not in ("published", "archived") + and self.scheduled_date < timezone.localdate() + ) 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..7671d62 --- /dev/null +++ b/content_calendar/templates/content_calendar/content/_toolbar.html @@ -0,0 +1,38 @@ +{% comment %}Shared toolbar for the content views. Expects: view, projects, current_project_id.{% endcomment %} +
+
+ {% with q=current_project_id %} + Table + Board + Calendar + {% endwith %} +
+ +
+ + + New content +
+
+ + 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 %} +
+ +
+ {% 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" %} + +
+

{{ month_label }}

+ +
+ +
+
+
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 %} +
+ Cancel + +
+
+
+
+{% 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..2ed499b --- /dev/null +++ b/content_calendar/templates/content_calendar/content/content_form.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} +{% block title %}{% if editing %}Edit content{% else %}New content{% endif %} - {{ site_config.site_name }}{% endblock %} + +{% block content %} +
+
+ ← Back to content + {% if editing %} + Delete + {% 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 }} + {% if field.help_text %}

{{ field.help_text }}

{% endif %} + {% if field.errors %}

{{ field.errors|join:", " }}

{% endif %} +
+ {% endfor %} + +
+ Cancel + +
+
+
+
+{% 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" %} + +
+ + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
ScheduledTopicTypePlatformsStatusApproval
+ {% 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 +
No content{% if current_project %} for {{ current_project.name }}{% endif %} yet.
+
+
+{% endblock %} diff --git a/content_calendar/tests.py b/content_calendar/tests.py new file mode 100644 index 0000000..e443a0d --- /dev/null +++ b/content_calendar/tests.py @@ -0,0 +1,72 @@ +# content_calendar/tests.py +from datetime import date + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from .models import ContentItem, Platform + + +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) diff --git a/content_calendar/urls.py b/content_calendar/urls.py new file mode 100644 index 0000000..efbe2dd --- /dev/null +++ b/content_calendar/urls.py @@ -0,0 +1,26 @@ +# 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("/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..aba6f3e --- /dev/null +++ b/content_calendar/views.py @@ -0,0 +1,190 @@ +# 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 projects.models import Project + +from .forms import ContentItemForm +from .models import ContentItem, Platform +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) diff --git a/pages/templates/pages/_sidebar.html b/pages/templates/pages/_sidebar.html index 7996ec3..efc1b4a 100644 --- a/pages/templates/pages/_sidebar.html +++ b/pages/templates/pages/_sidebar.html @@ -5,6 +5,7 @@ Dashboard Projects Tasks + Content CRM Assets Products diff --git a/templates/base.html b/templates/base.html index a25dd27..7d16e7f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -63,6 +63,7 @@ {% if sidebar_favorites %} diff --git a/requirements.txt b/requirements.txt index 3a671e6..a860780 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ urllib3 whitenoise anthropic openai -google-generativeai +google-genai # Desktop app (native window + production server for the bundled .exe) waitress diff --git a/templates/base.html b/templates/base.html index 7d16e7f..3468165 100644 --- a/templates/base.html +++ b/templates/base.html @@ -68,6 +68,7 @@ Assets Products Settings + AI From 6537c3eb945ebda2c2d993044413aaf760421d4e Mon Sep 17 00:00:00 2001 From: Diane Corriette <131492009+todiane@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:25:05 +0100 Subject: [PATCH 3/8] Phase 3: generation engine (voice, templates, Generate panel) Port ai-marketing's content engine into content_calendar, rewired to the Phase 2 provider-agnostic adapter. - VoiceProfile (single-user singleton) + distill_voice(): one AI call turns pasted writing samples into a structured, reusable voice. - PromptEnhancer: prompt layering adapted to return (system, user) for ai_client.generate() and to fold in the voice profile; ContentItem post types map onto the social prompt category. - ContentTemplate / ContentTemplatePrompt: a named recipe of ordered, per-field prompts (hook/caption/CTA/hashtags) that land in one ContentItem. - generate_content_item(): run a template or a free-text brief and create a draft ContentItem with approval_status="ready", using the active provider. - Generate panel + Voice profile pages (login-gated), toolbar "Generate" button, admin for templates/voice. Tests mock the adapter (no live calls). Co-Authored-By: Claude Opus 4.8 --- content_calendar/admin.py | 28 +- content_calendar/forms.py | 49 ++- content_calendar/generation.py | 282 ++++++++++++++++++ ...e_contenttemplate_contenttemplateprompt.py | 61 ++++ content_calendar/models.py | 104 ++++++- .../content_calendar/content/_toolbar.html | 2 + .../content/content_form.html | 6 + .../content_calendar/content/generate.html | 37 +++ .../content/voice_profile.html | 57 ++++ content_calendar/tests.py | 140 ++++++++- content_calendar/urls.py | 2 + content_calendar/views.py | 82 ++++- 12 files changed, 843 insertions(+), 7 deletions(-) create mode 100644 content_calendar/generation.py create mode 100644 content_calendar/migrations/0003_voiceprofile_contenttemplate_contenttemplateprompt.py create mode 100644 content_calendar/templates/content_calendar/content/generate.html create mode 100644 content_calendar/templates/content_calendar/content/voice_profile.html diff --git a/content_calendar/admin.py b/content_calendar/admin.py index 90bc728..75e8083 100644 --- a/content_calendar/admin.py +++ b/content_calendar/admin.py @@ -1,7 +1,13 @@ # content_calendar/admin.py from django.contrib import admin -from .models import ContentItem, Platform +from .models import ( + ContentItem, + ContentTemplate, + ContentTemplatePrompt, + Platform, + VoiceProfile, +) @admin.register(Platform) @@ -33,3 +39,23 @@ class ContentItemAdmin(admin.ModelAdmin): "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/forms.py b/content_calendar/forms.py index 99799e9..8532548 100644 --- a/content_calendar/forms.py +++ b/content_calendar/forms.py @@ -1,7 +1,9 @@ # content_calendar/forms.py from django import forms -from .models import ContentItem +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 " @@ -43,3 +45,48 @@ def __init__(self, *args, **kwargs): 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/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/models.py b/content_calendar/models.py index 0e7de09..0233a66 100644 --- a/content_calendar/models.py +++ b/content_calendar/models.py @@ -1,5 +1,6 @@ # content_calendar/models.py from django.db import models +from django.utils import timezone from projects.models import Project @@ -111,10 +112,109 @@ def __str__(self): @property def is_overdue(self): - from django.utils import timezone - 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/templates/content_calendar/content/_toolbar.html b/content_calendar/templates/content_calendar/content/_toolbar.html index 7671d62..c733bc7 100644 --- a/content_calendar/templates/content_calendar/content/_toolbar.html +++ b/content_calendar/templates/content_calendar/content/_toolbar.html @@ -18,6 +18,8 @@ {% endfor %} + ✨ Generate + New content diff --git a/content_calendar/templates/content_calendar/content/content_form.html b/content_calendar/templates/content_calendar/content/content_form.html index 2ed499b..49f6c21 100644 --- a/content_calendar/templates/content_calendar/content/content_form.html +++ b/content_calendar/templates/content_calendar/content/content_form.html @@ -10,6 +10,12 @@ {% endif %} + {% 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 %}

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 }} + {% if field.help_text %}

{{ field.help_text }}

{% endif %} + {% if field.errors %}

{{ field.errors|join:", " }}

{% endif %} +
+ {% endfor %} + +
+ Cancel + +
+
+
+
+{% 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.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 }} + {% if field.help_text %}

{{ field.help_text }}

{% endif %} + {% if field.errors %}

{{ field.errors|join:", " }}

{% endif %} +
+ {% endfor %} +
+ +
+
+
+
+{% endblock %} diff --git a/content_calendar/tests.py b/content_calendar/tests.py index e443a0d..fa6c220 100644 --- a/content_calendar/tests.py +++ b/content_calendar/tests.py @@ -1,11 +1,20 @@ # 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 .models import ContentItem, Platform +from . import generation +from .models import ( + ContentItem, + ContentTemplate, + ContentTemplatePrompt, + Platform, + VoiceProfile, +) class ContentCalendarTests(TestCase): @@ -70,3 +79,132 @@ 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 index efbe2dd..d08e089 100644 --- a/content_calendar/urls.py +++ b/content_calendar/urls.py @@ -17,6 +17,8 @@ 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"), diff --git a/content_calendar/views.py b/content_calendar/views.py index aba6f3e..f20be1a 100644 --- a/content_calendar/views.py +++ b/content_calendar/views.py @@ -12,10 +12,15 @@ 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 .forms import ContentItemForm -from .models import ContentItem, Platform +from . import generation +from .forms import ContentItemForm, GenerateForm, VoiceProfileForm +from .models import ContentItem, Platform, VoiceProfile from .serializers import ContentItemSerializer, PlatformSerializer @@ -188,3 +193,76 @@ def post(self, request, *args, **kwargs): 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}) From 1c9755bd6a8f6ebb8c9eb03697a9e5fad61b8439 Mon Sep 17 00:00:00 2001 From: Diane Corriette <131492009+todiane@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:30:48 +0100 Subject: [PATCH 4/8] Phase 4: ai_assistant (tools + agent loop + chat UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a chat assistant that acts on the calendar by calling tools. - tools.py: the shared tool set (create_content_item, list_content_items, update_status, reschedule_content_item, generate_draft, distill_voice, get_calendar_month) as plain functions returning JSON-serializable dicts, plus normalized schemas and an execute_tool dispatcher. This is the single source of truth reused verbatim by the Phase 5 MCP server. (reschedule_content_item is added beyond the plan's six tools to satisfy the "move Tuesday's post to Thursday" acceptance criterion cleanly.) - agent.py: run_turn() drives ai_client.generate_with_tools() in a loop — execute tool calls, feed results back, repeat until a plain-text reply, capped at MAX_STEPS. - Chat page: session-backed transcript, vanilla fetch + Tailwind (matching the app's existing JS conventions — the codebase has no HTMX), with a thinking indicator, Enter-to-send, and clear-chat. - Tests mock the adapter and cover every tool, the loop (incl. the cap), transcript filtering, and the chat views. Co-Authored-By: Claude Opus 4.8 --- ai_assistant/__init__.py | 0 ai_assistant/agent.py | 77 +++++ ai_assistant/apps.py | 7 + .../templates/ai_assistant/_messages.html | 20 ++ ai_assistant/templates/ai_assistant/chat.html | 100 +++++++ ai_assistant/tests.py | 157 ++++++++++ ai_assistant/tools.py | 279 ++++++++++++++++++ ai_assistant/urls.py | 12 + ai_assistant/views.py | 64 ++++ config/settings.py | 1 + config/urls.py | 1 + pages/templates/pages/_sidebar.html | 1 + templates/base.html | 1 + 13 files changed, 720 insertions(+) create mode 100644 ai_assistant/__init__.py create mode 100644 ai_assistant/agent.py create mode 100644 ai_assistant/apps.py create mode 100644 ai_assistant/templates/ai_assistant/_messages.html create mode 100644 ai_assistant/templates/ai_assistant/chat.html create mode 100644 ai_assistant/tests.py create mode 100644 ai_assistant/tools.py create mode 100644 ai_assistant/urls.py create mode 100644 ai_assistant/views.py 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/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' %} +
+
{{ m.content }}
+
+ {% else %} +
+
{{ m.content }}
+
+ {% endif %} +{% empty %} +

Ask me to plan, draft, schedule, or review your content.

+{% endfor %} +{% if error %} +
+
{{ 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

+
+ {% csrf_token %} + +
+
+ +
+
+ {% include "ai_assistant/_messages.html" %} +
+ +
+ {% csrf_token %} + + +
+
+

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/config/settings.py b/config/settings.py index eb5334a..bea6545 100644 --- a/config/settings.py +++ b/config/settings.py @@ -77,6 +77,7 @@ "sequences", "content_calendar", "ai_settings", + "ai_assistant", ] MIDDLEWARE = [ diff --git a/config/urls.py b/config/urls.py index 0c94741..1b14e87 100644 --- a/config/urls.py +++ b/config/urls.py @@ -30,6 +30,7 @@ def redirect_to_admin_login(request): 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/pages/templates/pages/_sidebar.html b/pages/templates/pages/_sidebar.html index 8040c5d..d12b9b1 100644 --- a/pages/templates/pages/_sidebar.html +++ b/pages/templates/pages/_sidebar.html @@ -6,6 +6,7 @@ Projects Tasks Content + Assistant CRM Assets Products diff --git a/templates/base.html b/templates/base.html index 3468165..abccb99 100644 --- a/templates/base.html +++ b/templates/base.html @@ -64,6 +64,7 @@ Projects Tasks Content + Assistant CRM Assets Products From c544d8451440294ca8352ae218c242b1e5bd6d74 Mon Sep 17 00:00:00 2001 From: Diane Corriette <131492009+todiane@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:40:41 +0100 Subject: [PATCH 5/8] Phase 5: MCP server (same tools over stdio) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the Phase 4 tool set to Claude Desktop / Cowork / Claude Code over MCP's stdio transport — no new business logic, a second transport. - mcp_server/server.py: FastMCP server with seven @mcp.tool() wrappers that call straight into ai_assistant.tools. Typed signatures give Claude clean input schemas. Runs standalone or imported after django.setup(). - runmcp management command launches it over stdio (stdout reserved for the protocol; status to stderr). - DJANGO_ALLOW_ASYNC_UNSAFE is set in the server: FastMCP runs sync tools on its event loop, and this single-user, one-request-at-a-time local server can safely run SQLite queries there rather than threading every ORM call. - mcp_server/README.md: connect-to-Claude doc — per-tool summary, one example prompt each, and the Desktop/Code config pointing at a local install. - Pin mcp<2 (the 1.x line providing the @server.tool()/FastMCP API the plan targets; 2.0 dropped it). Verified end-to-end via a real stdio handshake (initialize + tools/list + live tool call). Tests cover registration and the wrappers. Co-Authored-By: Claude Opus 4.8 --- ai_assistant/management/__init__.py | 0 ai_assistant/management/commands/__init__.py | 0 ai_assistant/management/commands/runmcp.py | 15 +++ mcp_server/README.md | 76 ++++++++++++ mcp_server/__init__.py | 0 mcp_server/server.py | 122 +++++++++++++++++++ mcp_server/tests.py | 46 +++++++ requirements.txt | 1 + 8 files changed, 260 insertions(+) create mode 100644 ai_assistant/management/__init__.py create mode 100644 ai_assistant/management/commands/__init__.py create mode 100644 ai_assistant/management/commands/runmcp.py create mode 100644 mcp_server/README.md create mode 100644 mcp_server/__init__.py create mode 100644 mcp_server/server.py create mode 100644 mcp_server/tests.py 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/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/mcp_server/README.md b/mcp_server/README.md new file mode 100644 index 0000000..4e83720 --- /dev/null +++ b/mcp_server/README.md @@ -0,0 +1,76 @@ +# 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. + +## What it needs + +- This project installed and working (its virtualenv, its SQLite database). +- The Python dependencies installed (`pip install -r requirements.txt`), which + now include `mcp`. +- An active AI provider configured in **AI Settings** — only required for the + `generate_draft` and `distill_voice` tools; the rest work without it. + +## 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/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..bd1c665 --- /dev/null +++ b/mcp_server/tests.py @@ -0,0 +1,46 @@ +# mcp_server/tests.py +import asyncio + +from django.test import TestCase + +from content_calendar.models import ContentItem + +from . import 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) diff --git a/requirements.txt b/requirements.txt index a860780..f79edc8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ whitenoise anthropic openai google-genai +mcp<2 # Desktop app (native window + production server for the bundled .exe) waitress From a3621485d2d9a86656aa889b6a6d8f4b5da6ffb3 Mon Sep 17 00:00:00 2001 From: Diane Corriette <131492009+todiane@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:49:05 +0100 Subject: [PATCH 6/8] Ignore local planning/prompt notes Keep CLAUDE_CODE_KICKOFF_PROMPT.md and CONTENT_OS_BUILD_PLAN.md on disk as local reference but out of the repository. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) 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 + From 9285855c95839101aad360aa3c46e900dbc3fc4b Mon Sep 17 00:00:00 2001 From: Diane Corriette <131492009+todiane@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:18:04 +0100 Subject: [PATCH 7/8] Auto-connect to Claude Desktop on first launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the MCP connection one-click for non-technical owners: on first run the app registers itself in Claude Desktop's config, so they never hand-edit JSON. - mcp_server/desktop_connect.py: safe, never-raising merge into claude_desktop_config.json. Preserves other keys/servers, refuses to clobber an unparseable file, idempotent. Dev points at `python manage.py runmcp`; packaged points at the sibling ProjectTracker-mcp.exe. - desktop.py: first-run hook (once, via a marker file; retries only while Claude Desktop is absent) + a self-clearing "restart Claude Desktop" banner shown via a ?claude=connected URL flag. - mcp_launcher.py: console entry point for the packaged MCP exe (stdio needs real stdin/stdout, which a windowed exe lacks) — also usable in dev. - connect_claude management command for a manual/assisted turn-on. - project_tracker.spec: FIX — it was missing all four new apps (packaged build would 500); add them + their templates, swap google.generativeai for google.genai, add mcp, and build a second console ProjectTracker-mcp.exe. - Docs: Requirements (needs Claude Desktop) + free-vs-API-key sections in the MCP README and a friendly Section 8 in HOW-TO. - Tests for the config merge (create/idempotent/preserve/no-claude/unreadable/ dev+frozen entry). Verified in dev: connect_claude is idempotent and mcp_launcher speaks stdio with all 7 tools. Co-Authored-By: Claude Opus 4.8 --- HOW-TO-OPEN-PROJECT-TRACKER.md | 31 ++++++ .../management/commands/connect_claude.py | 29 +++++ desktop.py | 42 ++++++- mcp_launcher.py | 42 +++++++ mcp_server/README.md | 24 +++- mcp_server/desktop_connect.py | 87 +++++++++++++++ mcp_server/tests.py | 70 +++++++++++- project_tracker.spec | 103 +++++++++++++++--- templates/base.html | 8 ++ 9 files changed, 415 insertions(+), 21 deletions(-) create mode 100644 ai_assistant/management/commands/connect_claude.py create mode 100644 mcp_launcher.py create mode 100644 mcp_server/desktop_connect.py 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/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/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 index 4e83720..1f8705a 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -8,13 +8,31 @@ 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. -## What it needs +## 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`. -- An active AI provider configured in **AI Settings** — only required for the - `generate_draft` and `distill_voice` tools; the rest work without it. + +## 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) 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/tests.py b/mcp_server/tests.py index bd1c665..1629afa 100644 --- a/mcp_server/tests.py +++ b/mcp_server/tests.py @@ -1,11 +1,14 @@ # mcp_server/tests.py import asyncio +import json +import tempfile +from pathlib import Path -from django.test import TestCase +from django.test import SimpleTestCase, TestCase from content_calendar.models import ContentItem -from . import server +from . import desktop_connect, server class McpServerTests(TestCase): @@ -44,3 +47,66 @@ def test_wrapper_get_calendar_month(self): 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/project_tracker.spec b/project_tracker.spec index 480d9a2..32e864c 100644 --- a/project_tracker.spec +++ b/project_tracker.spec @@ -6,8 +6,14 @@ 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 @@ -29,7 +35,8 @@ for pkg in [ "webview", "anthropic", "openai", - "google.generativeai", + "google.genai", # new Google GenAI SDK (replaces google-generativeai) + "mcp", # Model Context Protocol SDK (MCP server) ]: p_datas, p_binaries, p_hidden = collect_all(pkg) datas += p_datas @@ -38,7 +45,10 @@ for pkg in [ # 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 +61,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 +105,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 +139,8 @@ hiddenimports += [ ] -a = Analysis( +# --- Analysis 1: the app window (entry point desktop.py) ------------------- +a_app = Analysis( ["desktop.py"], pathex=[], binaries=binaries, @@ -117,12 +152,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 +172,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/templates/base.html b/templates/base.html index abccb99..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 %}