Add tier-gated RAG and Drive document sources (#42)
Ship allows_rag entitlement (founders/backer/pro/business), enforce it on document APIs and RAG chat, harden ingest/delete/active lifecycle, and add Google/Microsoft Drive connect + sync for personal and company knowledge bases. Closes #43 #44 #45 #46 #47 #48 #49 #50 #51 #52 #53 Parent epic: #42 Related: #11
This commit is contained in:
@@ -12,13 +12,14 @@ class SubscriptionPlanAdmin(admin.ModelAdmin):
|
||||
"is_public",
|
||||
"is_selectable",
|
||||
"allows_image_generation",
|
||||
"allows_rag",
|
||||
"allows_all_future_features",
|
||||
"prompt_quota_per_window",
|
||||
"prompt_window_hours",
|
||||
"monthly_token_quota",
|
||||
"sort_order",
|
||||
)
|
||||
list_filter = ("is_public", "is_selectable", "allows_image_generation")
|
||||
list_filter = ("is_public", "is_selectable", "allows_image_generation", "allows_rag")
|
||||
search_fields = ("slug", "name", "stripe_price_id")
|
||||
readonly_fields = ("created", "last_modified")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
|
||||
@@ -7,9 +7,113 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
def seed_plans(apps, schema_editor):
|
||||
from finance.services.plans import seed_subscription_plans
|
||||
"""Seed the original 5-plan catalog frozen at this migration's schema.
|
||||
|
||||
seed_subscription_plans(update_existing=True)
|
||||
Deliberately does NOT import ``finance.services.plans`` — that module's
|
||||
``PLAN_SEED``/model class reflect the *current* code, so a later required
|
||||
field (e.g. ``allows_rag`` added in #43) would make this historical
|
||||
RunPython try to write a column that doesn't exist yet when a fresh
|
||||
database replays migrations in order. Live code re-seeds (and adds any
|
||||
new fields) via ``seed_subscription_plans()`` calls elsewhere (app
|
||||
startup, quota checks, test setUp), so this only needs to create the
|
||||
original rows.
|
||||
"""
|
||||
SubscriptionPlan = apps.get_model("finance", "SubscriptionPlan")
|
||||
seed = [
|
||||
{
|
||||
"slug": "founders",
|
||||
"name": "Founders",
|
||||
"description": (
|
||||
"Unlimited product access for early supporters: text plus all future "
|
||||
"capabilities as they ship. $10/mo."
|
||||
),
|
||||
"price_cents": 1000,
|
||||
"is_public": True,
|
||||
"is_selectable": True,
|
||||
"allows_text_generation": True,
|
||||
"allows_image_generation": True,
|
||||
"allows_all_future_features": True,
|
||||
"prompt_quota_per_window": 300,
|
||||
"prompt_window_hours": 6,
|
||||
"monthly_token_quota": None,
|
||||
"sort_order": 10,
|
||||
},
|
||||
{
|
||||
"slug": "standard",
|
||||
"name": "Standard",
|
||||
"description": (
|
||||
"Secure conversational chat and coding assistance for developers "
|
||||
"and privacy-conscious individuals."
|
||||
),
|
||||
"price_cents": 1500,
|
||||
"is_public": False,
|
||||
"is_selectable": False,
|
||||
"allows_text_generation": True,
|
||||
"allows_image_generation": False,
|
||||
"allows_all_future_features": False,
|
||||
"prompt_quota_per_window": 100,
|
||||
"prompt_window_hours": 6,
|
||||
"monthly_token_quota": 1_000_000,
|
||||
"sort_order": 20,
|
||||
},
|
||||
{
|
||||
"slug": "pro",
|
||||
"name": "Pro / Creator",
|
||||
"description": (
|
||||
"Higher message caps and multi-modal workflows for heavy users, "
|
||||
"including image generation when available."
|
||||
),
|
||||
"price_cents": 4000,
|
||||
"is_public": False,
|
||||
"is_selectable": False,
|
||||
"allows_text_generation": True,
|
||||
"allows_image_generation": True,
|
||||
"allows_all_future_features": False,
|
||||
"prompt_quota_per_window": 200,
|
||||
"prompt_window_hours": 6,
|
||||
"monthly_token_quota": 3_000_000,
|
||||
"sort_order": 30,
|
||||
},
|
||||
{
|
||||
"slug": "business",
|
||||
"name": "Business Team",
|
||||
"description": (
|
||||
"Team seats, centralized auth, priority support, and absolute data "
|
||||
"privacy for local companies handling sensitive data."
|
||||
),
|
||||
"price_cents": 9900,
|
||||
"is_public": False,
|
||||
"is_selectable": False,
|
||||
"allows_text_generation": True,
|
||||
"allows_image_generation": True,
|
||||
"allows_all_future_features": False,
|
||||
"prompt_quota_per_window": 300,
|
||||
"prompt_window_hours": 6,
|
||||
"monthly_token_quota": 5_000_000,
|
||||
"sort_order": 40,
|
||||
},
|
||||
{
|
||||
"slug": "backer",
|
||||
"name": "Backer",
|
||||
"description": (
|
||||
"Complimentary Founders-level access for pre-approved emails. "
|
||||
"Not shown at checkout."
|
||||
),
|
||||
"price_cents": 0,
|
||||
"is_public": False,
|
||||
"is_selectable": False,
|
||||
"allows_text_generation": True,
|
||||
"allows_image_generation": True,
|
||||
"allows_all_future_features": True,
|
||||
"prompt_quota_per_window": 300,
|
||||
"prompt_window_hours": 6,
|
||||
"monthly_token_quota": None,
|
||||
"sort_order": 5,
|
||||
},
|
||||
]
|
||||
for row in seed:
|
||||
slug = row.pop("slug")
|
||||
SubscriptionPlan.objects.get_or_create(slug=slug, defaults=row)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0 on 2026-08-01 20:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("finance", "0003_subscription_cancel_period_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="subscriptionplan",
|
||||
name="allows_rag",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Drive/RAG document sync (Google Drive, OneDrive, SharePoint).",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -40,6 +40,10 @@ class SubscriptionPlan(TimeInfoBase):
|
||||
)
|
||||
allows_text_generation = models.BooleanField(default=True)
|
||||
allows_image_generation = models.BooleanField(default=False)
|
||||
allows_rag = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Drive/RAG document sync (Google Drive, OneDrive, SharePoint).",
|
||||
)
|
||||
allows_all_future_features = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Founders/Backer: unlock new capabilities as they ship.",
|
||||
@@ -74,6 +78,8 @@ class SubscriptionPlan(TimeInfoBase):
|
||||
return self.allows_text_generation
|
||||
if feature in ("image", "image_generation"):
|
||||
return self.allows_image_generation
|
||||
if feature in ("rag", "document_rag"):
|
||||
return self.allows_rag
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -82,5 +82,6 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
||||
return {
|
||||
"text_generation": obj.allows_feature("text_generation"),
|
||||
"image_generation": obj.allows_feature("image_generation"),
|
||||
"rag": obj.allows_feature("rag"),
|
||||
"all_future_features": obj.allows_all_future_features,
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ PLAN_SEED: list[dict[str, Any]] = [
|
||||
"is_selectable": True,
|
||||
"allows_text_generation": True,
|
||||
"allows_image_generation": True,
|
||||
"allows_rag": True,
|
||||
"allows_all_future_features": True,
|
||||
"prompt_quota_per_window": 300,
|
||||
"prompt_window_hours": 6,
|
||||
@@ -59,6 +60,7 @@ PLAN_SEED: list[dict[str, Any]] = [
|
||||
"is_selectable": False,
|
||||
"allows_text_generation": True,
|
||||
"allows_image_generation": False,
|
||||
"allows_rag": False,
|
||||
"allows_all_future_features": False,
|
||||
"prompt_quota_per_window": 100,
|
||||
"prompt_window_hours": 6,
|
||||
@@ -70,13 +72,14 @@ PLAN_SEED: list[dict[str, Any]] = [
|
||||
"name": "Pro / Creator",
|
||||
"description": (
|
||||
"Higher message caps and multi-modal workflows for heavy users, "
|
||||
"including image generation when available."
|
||||
"including image generation and Drive/RAG sync when available."
|
||||
),
|
||||
"price_cents": 4000,
|
||||
"is_public": False,
|
||||
"is_selectable": False,
|
||||
"allows_text_generation": True,
|
||||
"allows_image_generation": True,
|
||||
"allows_rag": True,
|
||||
"allows_all_future_features": False,
|
||||
"prompt_quota_per_window": 200,
|
||||
"prompt_window_hours": 6,
|
||||
@@ -87,14 +90,16 @@ PLAN_SEED: list[dict[str, Any]] = [
|
||||
"slug": SubscriptionPlan.Slug.BUSINESS,
|
||||
"name": "Business Team",
|
||||
"description": (
|
||||
"Team seats, centralized auth, priority support, and absolute data "
|
||||
"privacy for local companies handling sensitive data."
|
||||
"Team seats, centralized auth, priority support, company Drive/RAG "
|
||||
"sync, and absolute data privacy for local companies handling "
|
||||
"sensitive data."
|
||||
),
|
||||
"price_cents": 9900,
|
||||
"is_public": False,
|
||||
"is_selectable": False,
|
||||
"allows_text_generation": True,
|
||||
"allows_image_generation": True,
|
||||
"allows_rag": True,
|
||||
"allows_all_future_features": False,
|
||||
"prompt_quota_per_window": 300,
|
||||
"prompt_window_hours": 6,
|
||||
@@ -113,6 +118,7 @@ PLAN_SEED: list[dict[str, Any]] = [
|
||||
"is_selectable": False,
|
||||
"allows_text_generation": True,
|
||||
"allows_image_generation": True,
|
||||
"allows_rag": True,
|
||||
"allows_all_future_features": True,
|
||||
"prompt_quota_per_window": 300,
|
||||
"prompt_window_hours": 6,
|
||||
@@ -415,6 +421,7 @@ def plan_to_dict(plan: SubscriptionPlan | None) -> dict[str, Any] | None:
|
||||
"features": {
|
||||
"text_generation": plan.allows_feature("text_generation"),
|
||||
"image_generation": plan.allows_feature("image_generation"),
|
||||
"rag": plan.allows_feature("rag"),
|
||||
"all_future_features": plan.allows_all_future_features,
|
||||
},
|
||||
"prompt_quota_per_window": plan.prompt_quota_per_window,
|
||||
|
||||
@@ -55,6 +55,24 @@ class PlanCatalogTestCase(TestCase):
|
||||
self.assertFalse(plans["backer"].is_selectable)
|
||||
self.assertTrue(plans["backer"].allows_all_future_features)
|
||||
|
||||
def test_seed_allows_rag_matrix(self):
|
||||
"""#43: RAG is gated per-plan — standard is the only tier without it."""
|
||||
plans = {p.slug: p for p in seed_subscription_plans()}
|
||||
|
||||
self.assertTrue(plans["founders"].allows_rag)
|
||||
self.assertFalse(plans["standard"].allows_rag)
|
||||
self.assertTrue(plans["pro"].allows_rag)
|
||||
self.assertTrue(plans["business"].allows_rag)
|
||||
self.assertTrue(plans["backer"].allows_rag)
|
||||
|
||||
def test_allows_feature_recognizes_rag_aliases(self):
|
||||
plans = {p.slug: p for p in seed_subscription_plans()}
|
||||
|
||||
self.assertTrue(plans["pro"].allows_feature("rag"))
|
||||
self.assertTrue(plans["pro"].allows_feature("document_rag"))
|
||||
self.assertFalse(plans["standard"].allows_feature("rag"))
|
||||
self.assertFalse(plans["standard"].allows_feature("document_rag"))
|
||||
|
||||
|
||||
class BackerRedeemTestCase(TestCase):
|
||||
def setUp(self):
|
||||
@@ -126,6 +144,23 @@ class QuotaGateTestCase(TestCase):
|
||||
)
|
||||
assert_feature_allowed(self.user, "image_generation")
|
||||
|
||||
def test_business_allows_rag(self):
|
||||
business = SubscriptionPlan.objects.get(slug="business")
|
||||
assign_plan(self.user, plan=business, source=UserSubscription.Source.ADMIN)
|
||||
assert_feature_allowed(self.user, "rag")
|
||||
|
||||
def test_feature_gate_blocks_rag_on_standard(self):
|
||||
with self.assertRaises(FeatureNotAllowed) as ctx:
|
||||
assert_feature_allowed(self.user, "rag")
|
||||
self.assertEqual(ctx.exception.code, "feature_not_allowed")
|
||||
|
||||
def test_pro_allows_rag(self):
|
||||
pro = SubscriptionPlan.objects.get(slug="pro")
|
||||
assign_plan(
|
||||
self.user, plan=pro, source=UserSubscription.Source.ADMIN
|
||||
)
|
||||
assert_feature_allowed(self.user, "rag")
|
||||
|
||||
def test_token_quota_blocks_when_reported(self):
|
||||
self.plan.monthly_token_quota = 50
|
||||
self.plan.prompt_quota_per_window = 1000
|
||||
|
||||
Reference in New Issue
Block a user