From c3fb2c23f2a0e01fd647b17c6cce15d821c81f03 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sat, 17 Oct 2026 07:19:03 -0500 Subject: [PATCH] Add public waitlist and usage counts for the marketing site. Counts stay aggregate, and joining the waitlist does not create an account. Closes #74. Co-authored-by: Cursor --- .../migrations/0034_waitlistentry.py | 37 +++++++++++ llm_be/chat_backend/models.py | 12 ++++ .../tests/test_public_marketing.py | 65 +++++++++++++++++++ llm_be/chat_backend/urls.py | 4 ++ llm_be/chat_backend/views.py | 50 ++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 llm_be/chat_backend/migrations/0034_waitlistentry.py create mode 100644 llm_be/chat_backend/tests/test_public_marketing.py diff --git a/llm_be/chat_backend/migrations/0034_waitlistentry.py b/llm_be/chat_backend/migrations/0034_waitlistentry.py new file mode 100644 index 0000000..5505357 --- /dev/null +++ b/llm_be/chat_backend/migrations/0034_waitlistentry.py @@ -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"], + }, + ), + ] diff --git a/llm_be/chat_backend/models.py b/llm_be/chat_backend/models.py index 9cf359f..8a18ed7 100644 --- a/llm_be/chat_backend/models.py +++ b/llm_be/chat_backend/models.py @@ -792,3 +792,15 @@ class StoredFile(TimeInfoBase): def __str__(self): 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 diff --git a/llm_be/chat_backend/tests/test_public_marketing.py b/llm_be/chat_backend/tests/test_public_marketing.py new file mode 100644 index 0000000..1766d86 --- /dev/null +++ b/llm_be/chat_backend/tests/test_public_marketing.py @@ -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) diff --git a/llm_be/chat_backend/urls.py b/llm_be/chat_backend/urls.py index f8ccc17..a33a1a3 100644 --- a/llm_be/chat_backend/urls.py +++ b/llm_be/chat_backend/urls.py @@ -9,6 +9,8 @@ from .views import ( LogoutAndBlacklistRefreshTokenForUserView, CustomUserGet, PublicSettingsView, + PublicUsageView, + PublicWaitlistView, is_authenticated, AnnouncmentView, FeedbackView, @@ -46,6 +48,8 @@ urlpatterns = [ path("token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"), path("user/create/", CustomUserCreate.as_view(), name="create_user"), 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( "auth/oauth//start/", OAuthStartView.as_view(), diff --git a/llm_be/chat_backend/views.py b/llm_be/chat_backend/views.py index f9b42cd..b516e25 100644 --- a/llm_be/chat_backend/views.py +++ b/llm_be/chat_backend/views.py @@ -32,6 +32,7 @@ from .models import ( DocumentWorkspace, Document, UserAuthEvent, + WaitlistEntry, ) from django.views.decorators.cache import never_cache 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): """Self-serve registration. Gated by ENABLE_ACCOUNT_REGISTRATION (default off)."""