Headless API Gap Analysis & Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task.
Goal: Implement all stubbed headless API endpoints that the frontend depends on, bringing the Django backend to full parity with the frontend's expected API surface.
Architecture: Each task creates a Model (if needed), a view implementation, and wires the URL. Stubs are replaced with real DB queries following existing patterns in the codebase.
Tech Stack: Django 6, Django Rest Framework, PostgreSQL
Gap Analysis Summary
| Category | Total | Implemented | Partial | Stub |
|---|---|---|---|---|
| Auth/User | 6 | 4 | 1 | 1 |
| Spaces | 8 | 4 | 0 | 4 |
| Posts | 6 | 5 | 0 | 1 |
| Comments | 2 | 2 | 0 | 0 |
| Notifications | 7 | 5 | 2 | 0 |
| Notification Prefs | 3 | 3 | 0 | 0 |
| Bookmarks | 2 | 0 | 0 | 2 |
| Events | 2 | 0 | 0 | 2 |
| Recurring Events | 2 | 0 | 0 | 2 |
| Community | 2 | 1 | 0 | 1 |
| Search | 1 | 1 | 0 | 0 |
| Reactions | 1 | 0 | 0 | 1 |
| Invitations | 1 | 1 | 0 | 0 |
| Courses | 3 | 3 | 0 | 0 |
| Chat/Messages | 6 | 0 | 0 | 6 |
| TOTAL | 52 | 29 | 3 | 20 |
File Structure
apps/
├── spaces/
│ ├── models/
│ │ ├── bookmark.py # (new) Bookmark model
│ │ └── post_follower.py # (new) PostFollower model
│ ├── migrations/
│ │ └── 0007_bookmark.postfollower.py
│ └── views/
│ └── headless_events.py # (new) event headless views
├── headless/
│ ├── views.py # (modify) replace stubs
│ └── urls.py # (modify) add missing routes
└── tests/
└── test_headless_gaps.py # (new) tests for new implementationsTask 1: Bookmark Model + Migration
Covers: BookmarkListView, BookmarkDeleteView, useBookmarks(), useToggleBookmark(), useSpaceBookmarks()
Files:
Create:
apps/spaces/models/bookmark.pyModify:
apps/spaces/models/__init__.pyCreate:
apps/spaces/migrations/0007_bookmark.py[ ] Step 1: Create Bookmark model
# apps/spaces/models/bookmark.py
from django.conf import settings
from django.db import models
from apps.utils.models import BaseModel
class Bookmark(BaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="bookmarks",
)
community = models.ForeignKey(
"communities.Community",
on_delete=models.CASCADE,
related_name="bookmarks",
)
post = models.ForeignKey(
"spaces.Post",
on_delete=models.CASCADE,
related_name="bookmarks",
)
class Meta:
unique_together = ["user", "post"]
def __str__(self):
return f"{self.user} bookmarked {self.post}"- [ ] Step 2: Export from
__init__.py
Add to apps/spaces/models/__init__.py:
from apps.spaces.models.bookmark import BookmarkAnd add "Bookmark" to __all__.
- [ ] Step 3: Create and apply migration
Run: make manage ARGS='makemigrations spaces' Run: make migrate
- [ ] Step 4: Commit
git add apps/spaces/models/bookmark.py apps/spaces/models/__init__.py apps/spaces/migrations/
git commit -m "feat: add Bookmark model"Task 2: PostFollower Model + Migration
Covers: PostFollowersView, PostFollowerToggleView, usePostFollowers(), useFollowPost()
Files:
Create:
apps/spaces/models/post_follower.pyModify:
apps/spaces/models/__init__.pyCreate:
apps/spaces/migrations/0008_postfollower.py[ ] Step 1: Create PostFollower model
# apps/spaces/models/post_follower.py
from django.conf import settings
from django.db import models
from apps.utils.models import BaseModel
class PostFollower(BaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="post_follows",
)
post = models.ForeignKey(
"spaces.Post",
on_delete=models.CASCADE,
related_name="followers",
)
class Meta:
unique_together = ["user", "post"]
def __str__(self):
return f"{self.user} follows {self.post}"- [ ] Step 2: Export and migrate
Add to apps/spaces/models/__init__.py:
from apps.spaces.models.post_follower import PostFollowerRun: make manage ARGS='makemigrations spaces' Run: make migrate
- [ ] Step 3: Commit
git add apps/spaces/models/post_follower.py apps/spaces/models/__init__.py apps/spaces/migrations/
git commit -m "feat: add PostFollower model"Task 3: Implement Bookmarks Headless Views
Covers: BookmarkListView, BookmarkDeleteView, useBookmarks(), useToggleBookmark(), useSpaceBookmarks()
Files:
Modify:
apps/headless/views.py— replace BookmarkListView, BookmarkDeleteView stubs; add wiring for BookmarkCreateView[ ] Step 1: Replace BookmarkListView stub
# Replace in apps/headless/views.py
class BookmarkListView(APIView):
"""GET /api/headless/v1/bookmarks."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def get(self, request, *args, **kwargs):
from apps.spaces.models import Bookmark
bookmarks = Bookmark.objects.filter(
user=request.user,
community=request.community,
).select_related("post__space").order_by("-created_at")
paginator = HeadlessPagination()
page = paginator.paginate_queryset(bookmarks, request)
records = []
for b in (page or bookmarks):
records.append({
"id": b.id,
"record_id": b.post_id,
"bookmarkable_type": "Post",
"created_at": b.created_at.isoformat(),
"bookmark_record": {
"id": b.post.id,
"name": b.post.body[:100],
"slug": str(b.post.id),
"url": f"/posts/{b.post.id}",
"space": {
"id": b.post.space.id,
"name": b.post.space.name,
"slug": b.post.space.slug,
},
},
})
return paginator.get_paginated_response(records)- [ ] Step 2: Replace BookmarkDeleteView stub
class BookmarkDeleteView(APIView):
"""DELETE /api/headless/v1/bookmarks/{postId}."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def delete(self, request, post_id, *args, **kwargs):
from apps.spaces.models import Bookmark
Bookmark.objects.filter(
user=request.user,
community=request.community,
post_id=post_id,
).delete()
return Response({"success": True})- [ ] Step 3: Add BookmarkCreateView (POST /bookmarks)
class BookmarkCreateView(APIView):
"""POST /api/headless/v1/bookmarks."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def post(self, request, *args, **kwargs):
from apps.spaces.models import Bookmark, Post
post_id = request.data.get("bookmarkable_id")
post = get_object_or_404(Post, pk=post_id, community=request.community)
bookmark, created = Bookmark.objects.get_or_create(
user=request.user,
community=request.community,
post=post,
)
return Response({"success": True}, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)- [ ] Step 4: Wire URL
Add to apps/headless/urls.py:
path("bookmarks", views.BookmarkCreateView.as_view(), name="bookmarks-create"),
path("bookmarks/<int:post_id>", views.BookmarkDeleteView.as_view(), name="bookmarks-delete"),Replace existing stub URLs.
- [ ] Step 5: Commit
git add apps/headless/views.py apps/headless/urls.py
git commit -m "feat: implement bookmark headless API endpoints"Task 4: Implement PostFollowers Headless Views
Covers: PostFollowersView, PostFollowerToggleView, usePostFollowers(), useFollowPost()
Files:
Modify:
apps/headless/views.py— replace PostFollowersView, PostFollowerToggleView stubs[ ] Step 1: Replace PostFollowersView stub
class PostFollowersView(APIView):
"""GET /api/headless/v1/posts/{postId}/post_followers."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def get(self, request, post_id, *args, **kwargs):
from apps.spaces.models import PostFollower
followers = PostFollower.objects.filter(
post_id=post_id,
post__community=request.community,
).select_related("user")
records = []
for f in followers:
records.append({
"id": f.id,
"post_id": f.post_id,
"community_member_id": f.user.id,
"name": f.user.get_display_name(),
"avatar_url": f.user.avatar_url if f.user.avatar else None,
})
return Response({
"page": 1,
"per_page": 20,
"has_next_page": False,
"count": len(records),
"page_count": 1,
"records": records,
})- [ ] Step 2: Replace PostFollowerToggleView stub
class PostFollowerToggleView(APIView):
"""POST/DELETE /api/headless/v1/posts/{postId}/post_followers."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def post(self, request, post_id, *args, **kwargs):
from apps.spaces.models import PostFollower, Post
post = get_object_or_404(Post, pk=post_id, community=request.community)
follower, created = PostFollower.objects.get_or_create(
user=request.user,
post=post,
)
return Response({"success": True, "is_following": True})
def delete(self, request, post_id, *args, **kwargs):
from apps.spaces.models import PostFollower
PostFollower.objects.filter(
user=request.user,
post_id=post_id,
).delete()
return Response({"success": True, "is_following": False})- [ ] Step 3: Commit
git add apps/headless/views.py
git commit -m "feat: implement post followers headless API endpoints"Task 5: Implement Events Headless Views
Covers: CommunityEventsView, EventAttendeesView, useCommunityEvents(), useEventAttendees(), useRsvpEvent(), useCancelRsvp()
Files:
Modify:
apps/headless/views.py— replace CommunityEventsView, EventAttendeesView stubs; wire EventAttendeeCreateView, EventAttendeeDeleteView[ ] Step 1: Replace CommunityEventsView stub
class CommunityEventsView(APIView):
"""GET /api/headless/v1/community_events."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def get(self, request, *args, **kwargs):
from apps.spaces.models import Event, RSVP
events = Event.objects.filter(
community=request.community,
space__isnull=False,
).select_related("host__user", "space").order_by("starts_at")
paginator = HeadlessPagination()
page = paginator.paginate_queryset(events, request)
records = []
for e in (page or events):
rsvp = RSVP.objects.filter(event=e, membership=request.membership).first()
records.append({
"id": e.id,
"display_title": e.title,
"slug": str(e.id),
"body_plain_text": e.description,
"url": f"/events/{e.id}",
"created_at": e.created_at.isoformat(),
"event_type": "virtual" if e.virtual_meeting_url else "in_person",
"rsvp_count": RSVP.objects.filter(event=e, status=RSVP.Status.GOING).count(),
"rsvped_event": rsvp.status if rsvp else None,
"event_setting_attributes": {
"starts_at": e.starts_at.isoformat(),
"ends_at": e.ends_at.isoformat(),
"in_person_location": e.location,
"virtual_location_url": e.virtual_meeting_url,
"rsvp_limit": None,
},
"space": {"id": e.space.id, "name": e.space.name, "slug": e.space.slug},
"author": {
"id": e.host.id,
"name": e.host.user.get_display_name(),
"avatar_url": e.host.user.avatar_url if e.host.user.avatar else None,
},
})
return paginator.get_paginated_response(records)- [ ] Step 2: Replace EventAttendeesView stub
class EventAttendeesView(APIView):
"""GET/POST/DELETE /api/headless/v1/events/{eventId}/event_attendees."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def get(self, request, event_id, *args, **kwargs):
from apps.spaces.models import Event, RSVP
event = get_object_or_404(Event, pk=event_id, community=request.community)
rsvps = RSVP.objects.filter(event=event).select_related("membership__user")
records = []
for r in rsvps:
records.append({
"id": r.id,
"community_member_id": r.membership.id,
"avatar_url": r.membership.user.avatar_url if r.membership.user.avatar else None,
"rsvp_date": r.created_at.isoformat(),
"rsvp_status": r.status,
"name": r.membership.user.get_display_name(),
})
return Response({
"page": 1,
"per_page": 20,
"has_next_page": False,
"count": len(records),
"page_count": 1,
"records": records,
})
def post(self, request, event_id, *args, **kwargs):
from apps.spaces.models import Event, RSVP
event = get_object_or_404(Event, pk=event_id, community=request.community)
rsvp, _ = RSVP.objects.update_or_create(
event=event,
membership=request.membership,
defaults={"status": RSVP.Status.GOING},
)
return Response({"success": True})
def delete(self, request, event_id, *args, **kwargs):
from apps.spaces.models import RSVP
RSVP.objects.filter(
event_id=event_id,
membership=request.membership,
).delete()
return Response({"success": True})- [ ] Step 3: Commit
git add apps/headless/views.py
git commit -m "feat: implement events headless API endpoints"Task 6: Implement Space Join/Leave/Topics/Bookmarks
Covers: SpaceJoinView, SpaceLeaveView, SpaceTopicsView, SpaceBookmarksView
Files:
Modify:
apps/headless/views.py[ ] Step 1: Replace SpaceJoinView/SpaceLeaveView stubs
These are already partially implemented (return success:True). The Space model uses access_groups for access control, not membership. Leave as-is for now — the stub behavior is correct for public spaces.
- [ ] Step 2: Replace SpaceTopicsView stub
class SpaceTopicsView(APIView):
"""GET /api/headless/v1/spaces/{spaceId}/topics."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def get(self, request, space_id, *args, **kwargs):
from django.db.models import Count
from apps.spaces.models import Space, Tag
space = get_object_or_404(
Space.objects.visible_to(request.membership),
pk=space_id,
)
tags = Tag.objects.filter(
community=request.community,
post__space=space,
).annotate(posts_count=Count("post")).order_by("-posts_count")
records = []
for t in tags:
records.append({
"id": t.id,
"name": t.name,
"slug": t.slug,
"posts_count": t.posts_count,
"space_id": space.id,
})
return Response({
"page": 1,
"per_page": 20,
"has_next_page": False,
"count": len(records),
"page_count": 1,
"records": records,
})- [ ] Step 3: Replace SpaceBookmarksView stub
class SpaceBookmarksView(APIView):
"""GET /api/headless/v1/spaces/{spaceId}/bookmarks."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def get(self, request, space_id, *args, **kwargs):
from apps.spaces.models import Bookmark, Space
space = get_object_or_404(
Space.objects.visible_to(request.membership),
pk=space_id,
)
bookmarks = Bookmark.objects.filter(
user=request.user,
community=request.community,
post__space=space,
).select_related("post")
records = []
for b in bookmarks:
records.append({
"id": b.id,
"name": b.post.body[:100],
"display_type": "post",
"links": [{
"id": b.post.id,
"name": b.post.body[:100],
"display_type": "post",
"url": f"/posts/{b.post.id}",
"emoji": None,
"custom_emoji_url": None,
}],
})
return Response({
"page": 1,
"per_page": 20,
"has_next_page": False,
"count": len(records),
"page_count": 1,
"records": records,
})- [ ] Step 4: Commit
git add apps/headless/views.py
git commit -m "feat: implement space topics and bookmarks headless endpoints"Task 7: Implement Reactions Headless View
Covers: ReactionCreateView, useAddReaction()
Files:
Modify:
apps/headless/views.py[ ] Step 1: Replace ReactionCreateView stub
class ReactionCreateView(APIView):
"""POST /api/headless/v1/reactions."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def post(self, request, *args, **kwargs):
from django.contrib.contenttypes.models import ContentType
from apps.spaces.models import Post, Comment, Image, Reaction
reactable_type = request.data.get("reactable_type", "")
reactable_id = request.data.get("reactable_id")
emoji = request.data.get("reaction", "like")
model_map = {"Post": Post, "Comment": Comment, "Image": Image}
model = model_map.get(reactable_type)
if model is None:
return Response({"error": "Invalid reactable_type"}, status=status.HTTP_400_BAD_REQUEST)
target = get_object_or_404(model, pk=reactable_id, community=request.community)
content_type = ContentType.objects.get_for_model(target)
reaction, _ = Reaction.objects.update_or_create(
membership=request.membership,
content_type=content_type,
object_id=target.pk,
defaults={"emoji": emoji},
)
return Response({"id": reaction.id, "reaction": reaction.emoji})- [ ] Step 2: Commit
git add apps/headless/views.py
git commit -m "feat: implement reaction create headless endpoint"Task 8: Implement Community Links + Member Spaces
Covers: CommunityLinksView, MemberSpacesView, useCommunityLinks(), useMemberSpaces()
Files:
Modify:
apps/headless/views.py[ ] Step 1: Replace CommunityLinksView stub
class CommunityLinksView(APIView):
"""GET /api/headless/v1/community_links."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def get(self, request, *args, **kwargs):
# No CommunityLink model exists yet — return empty for now
# This is expected: Circle.so custom links are a premium feature
return Response({
"page": 1,
"per_page": 20,
"has_next_page": False,
"count": 0,
"page_count": 1,
"records": [],
})- [ ] Step 2: Replace MemberSpacesView stub
class MemberSpacesView(APIView):
"""GET /api/headless/v1/community_members/{memberId}/spaces."""
permission_classes = [RequiresCommunity, permissions.IsAuthenticated, RequiresActiveMembership]
def get(self, request, member_id, *args, **kwargs):
from apps.spaces.models import Space
membership = get_object_or_404(
Membership, pk=member_id, community=request.community
)
spaces = Space.objects.visible_to(membership).select_related("space_group")
records = []
for s in spaces:
records.append({
"id": s.id,
"name": s.name,
"slug": s.slug,
"space_type": s.space_type,
"space_group_id": s.space_group_id,
})
return Response({
"page": 1,
"per_page": 20,
"has_next_page": False,
"count": len(records),
"page_count": 1,
"records": records,
})- [ ] Step 3: Commit
git add apps/headless/views.py
git commit -m "feat: implement community links and member spaces headless endpoints"Task 9: Partial Implementations — Fix Hardcoded Fields
Covers: PublicMemberProfileView, NotificationsNewCountView, SpaceNotificationDetailsView, PageProfileFieldsView
Files:
Modify:
apps/headless/views.py[ ] Step 1: Fix PublicMemberProfileView — add comments_count, followers_count
# In PublicMemberProfileView.get(), add:
from apps.spaces.models import Comment, PostFollower
comments_count = Comment.objects.filter(
author=membership,
community=request.community,
).count()
followers_count = PostFollower.objects.filter(
post__author=membership,
post__community=request.community,
).values("user").distinct().count()Include in response: "comments_count": comments_count, "followers_count": followers_count
- [ ] Step 2: Fix PageProfileFieldsView — include required and options
# In PageProfileFieldsView.get(), update the record building:
records.append({
"id": d.id,
"key": d.key,
"label": d.name,
"field_type": d.field_type,
"required": d.is_required,
"options": d.options or [],
})- [ ] Step 3: Commit
git add apps/headless/views.py
git commit -m "feat: fix hardcoded fields in partial headless implementations"Task 10: Tests
Covers: All new implementations
Files:
Create:
apps/headless/tests/test_headless_gaps.py[ ] Step 1: Write tests for bookmarks
class BookmarkAPITests(CommunityTestCase):
def _login(self):
user, _ = CustomUser.objects.get_or_create(
username="bookmark@example.com",
defaults={"email": "bookmark@example.com"},
)
user.set_password(PASSWORD)
user.save()
self.client.login(username="bookmark@example.com", password=PASSWORD)
return user
def test_create_and_list_bookmarks(self):
user = self._login()
membership = self.create_membership(user)
space = self.create_space(community=self.community)
post = self.create_post(space=space, author=membership)
response = self.client.post(
"/api/headless/v1/bookmarks",
data={"bookmarkable_type": "Post", "bookmarkable_id": post.id},
content_type="application/json",
HTTP_HOST=self.community.slug + ".localhost",
)
self.assertEqual(response.status_code, 201)
response = self.client.get(
"/api/headless/v1/bookmarks",
HTTP_HOST=self.community.slug + ".localhost",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 1)[ ] Step 2: Write tests for post followers
[ ] Step 3: Write tests for events
[ ] Step 4: Run tests
Run: make test ARGS='apps.headless.tests.test_headless_gaps' Expected: All tests pass
- [ ] Step 5: Commit
git add apps/headless/tests/test_headless_gaps.py
git commit -m "feat: add tests for headless API gap implementations"Task 11: Verify & Lint
- [ ] Step 1: Run full test suite
Run: make test Expected: All tests pass (pre-existing JWT failures only)
- [ ] Step 2: Run linter
Run: make ruff Expected: No new errors
- [ ] Step 3: Commit any fixes
git add -A
git commit -m "fix: lint and formatting for headless gap implementations"