Add public waitlist and usage counts for the marketing site.
CI / test (pull_request) Successful in 51s
Unit Tests / test (pull_request) Successful in 9s

Counts stay aggregate, and joining the waitlist does not create an account.
Closes #74.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
westfarnandCursor committed 2026-10-17 07:19:03 -05:00
1 parent 093e5462a5
commit c3fb2c23f2
5 files changed
+168

No files matched your search

@@ -0,0 +1,37 @@
# Generated by Django 6.0 on 2026-10-17
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("chat_backend", "0033_agentrun_agentstep"),
]
operations = [
migrations.CreateModel(
name="WaitlistEntry",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(default=django.utils.timezone.now)),
(
"last_modified",
models.DateTimeField(default=django.utils.timezone.now),
),
("email", models.EmailField(max_length=254, unique=True)),
],
options={
"ordering": ["-created"],
},
),
]
+12
View File
@@ -792,3 +792,15 @@ class StoredFile(TimeInfoBase):
def __str__(self): def __str__(self):
return self.name return self.name
class WaitlistEntry(TimeInfoBase):
"""Email captured from the public site when self-serve registration is off."""
email = models.EmailField(unique=True)
class Meta:
ordering = ["-created"]
def __str__(self):
return self.email
@@ -0,0 +1,65 @@
from django.contrib.auth import get_user_model
from django.urls import reverse
from rest_framework.test import APITestCase
from chat_backend.models import Document, WaitlistEntry
from chat_backend.tests.factories import make_company, make_document, make_user, make_workspace
class PublicUsageViewTests(APITestCase):
def test_counts_active_accounts_and_documents_without_personal_data(self):
company = make_company()
make_user(email="active@example.com", company=company)
inactive = make_user(email="inactive@example.com")
inactive.is_active = False
inactive.save(update_fields=["is_active"])
deleted = make_user(email="deleted@example.com")
deleted.deleted = True
deleted.save(update_fields=["deleted"])
make_document(make_workspace(company=company))
response = self.client.get(reverse("public_usage"))
self.assertEqual(response.status_code, 200)
self.assertEqual(
set(response.data.keys()),
{"account_count", "document_count", "as_of"},
)
self.assertEqual(response.data["account_count"], 1)
self.assertEqual(response.data["document_count"], Document.objects.count())
self.assertIn("T", response.data["as_of"])
body = response.content.decode()
self.assertNotIn("active@example.com", body)
self.assertNotIn("avatar", body)
def test_does_not_require_authentication(self):
response = self.client.get(reverse("public_usage"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["account_count"], 0)
class PublicWaitlistViewTests(APITestCase):
def test_stores_an_email_once(self):
url = reverse("public_waitlist")
first = self.client.post(url, {"email": " Person@Example.com "}, format="json")
second = self.client.post(url, {"email": "person@example.com"}, format="json")
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(first.data["detail"], "You're on the list.")
self.assertEqual(second.data["detail"], "You're on the list.")
self.assertEqual(WaitlistEntry.objects.count(), 1)
self.assertEqual(WaitlistEntry.objects.get().email, "person@example.com")
self.assertEqual(get_user_model().objects.count(), 0)
def test_rejects_a_bad_email(self):
response = self.client.post(
reverse("public_waitlist"), {"email": "not-an-email"}, format="json"
)
self.assertEqual(response.status_code, 400)
self.assertEqual(WaitlistEntry.objects.count(), 0)
def test_rejects_a_missing_email(self):
response = self.client.post(reverse("public_waitlist"), {}, format="json")
self.assertEqual(response.status_code, 400)
self.assertEqual(WaitlistEntry.objects.count(), 0)
+4
View File
@@ -9,6 +9,8 @@ from .views import (
LogoutAndBlacklistRefreshTokenForUserView, LogoutAndBlacklistRefreshTokenForUserView,
CustomUserGet, CustomUserGet,
PublicSettingsView, PublicSettingsView,
PublicUsageView,
PublicWaitlistView,
is_authenticated, is_authenticated,
AnnouncmentView, AnnouncmentView,
FeedbackView, FeedbackView,
@@ -46,6 +48,8 @@ urlpatterns = [
path("token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"), path("token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"),
path("user/create/", CustomUserCreate.as_view(), name="create_user"), path("user/create/", CustomUserCreate.as_view(), name="create_user"),
path("public/settings/", PublicSettingsView.as_view(), name="public_settings"), path("public/settings/", PublicSettingsView.as_view(), name="public_settings"),
path("public/usage/", PublicUsageView.as_view(), name="public_usage"),
path("public/waitlist/", PublicWaitlistView.as_view(), name="public_waitlist"),
path( path(
"auth/oauth/<str:provider>/start/", "auth/oauth/<str:provider>/start/",
OAuthStartView.as_view(), OAuthStartView.as_view(),
+50
View File
@@ -32,6 +32,7 @@ from .models import (
DocumentWorkspace, DocumentWorkspace,
Document, Document,
UserAuthEvent, UserAuthEvent,
WaitlistEntry,
) )
from django.views.decorators.cache import never_cache from django.views.decorators.cache import never_cache
from django.http import JsonResponse from django.http import JsonResponse
@@ -127,6 +128,55 @@ class PublicSettingsView(APIView):
) )
class PublicUsageView(APIView):
"""Aggregate counts for the public site. No emails, names, or avatars."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def get(self, request):
from django.utils import timezone
return Response(
{
"account_count": CustomUser.objects.filter(
deleted=False, is_active=True
).count(),
"document_count": Document.objects.count(),
"as_of": timezone.now().isoformat(),
}
)
class PublicWaitlistView(APIView):
"""Store a waitlist email. Same response for new and existing addresses."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def post(self, request):
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
raw = request.data.get("email") if isinstance(request.data, dict) else None
email = str(raw or "").strip().lower()
if len(email) > 254:
return Response(
{"detail": "Enter a valid email."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
validate_email(email)
except ValidationError:
return Response(
{"detail": "Enter a valid email."},
status=status.HTTP_400_BAD_REQUEST,
)
WaitlistEntry.objects.get_or_create(email=email)
return Response({"detail": "You're on the list."}, status=status.HTTP_200_OK)
class CustomUserCreate(APIView): class CustomUserCreate(APIView):
"""Self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION (default off).""" """Self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION (default off)."""