From 3a52a99cc1b9b177eb3eb5b1d296d01df99ae968 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:30:40 +0000 Subject: [PATCH 01/29] chore(deps): bump python-dotenv from 1.0.1 to 1.2.2 Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 1.0.1 to 1.2.2. - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v1.0.1...v1.2.2) --- updated-dependencies: - dependency-name: python-dotenv dependency-version: 1.2.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ba1f2eb..27ac872 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ Django==5.0.6 celery==5.4.0 cryptography==43.0.3 -python-dotenv==1.0.1 +python-dotenv==1.2.2 requests==2.31.0 gunicorn==22.0.0 gevent==24.2.1 From b7dd800cc48a31c92fee0d4e31fe997ec6379e65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:31:05 +0000 Subject: [PATCH 02/29] chore(deps): bump requests from 2.31.0 to 2.33.0 Bumps [requests](https://github.com/psf/requests) from 2.31.0 to 2.33.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.31.0...v2.33.0) --- updated-dependencies: - dependency-name: requests dependency-version: 2.33.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ba1f2eb..bc3076b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ Django==5.0.6 celery==5.4.0 cryptography==43.0.3 python-dotenv==1.0.1 -requests==2.31.0 +requests==2.33.0 gunicorn==22.0.0 gevent==24.2.1 drf-yasg==1.21.7 From df4d3a4f6d19744d4e850abe943cff049f569a29 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:31:41 +0000 Subject: [PATCH 03/29] chore(deps): bump django from 5.0.6 to 5.1.15 Bumps [django](https://github.com/django/django) from 5.0.6 to 5.1.15. - [Commits](https://github.com/django/django/compare/5.0.6...5.1.15) --- updated-dependencies: - dependency-name: django dependency-version: 5.1.15 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ba1f2eb..d10dfea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Core -Django==5.0.6 +Django==5.1.15 celery==5.4.0 cryptography==43.0.3 python-dotenv==1.0.1 From 8aa0a7e781143be0af01f8b5ff2415cd71a48741 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:31:59 +0000 Subject: [PATCH 04/29] chore(deps): bump cryptography from 43.0.3 to 50.0.0 Bumps [cryptography](https://github.com/pyca/cryptography) from 43.0.3 to 50.0.0. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/43.0.3...50.0.0) --- updated-dependencies: - dependency-name: cryptography dependency-version: 50.0.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ba1f2eb..030eb65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # Core Django==5.0.6 celery==5.4.0 -cryptography==43.0.3 +cryptography==50.0.0 python-dotenv==1.0.1 requests==2.31.0 gunicorn==22.0.0 From 1e0caa39f9e6ba4f5787d2839a2edfd5f33fe947 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 10:27:10 +0700 Subject: [PATCH 05/29] feat: implement migration file for billing --- apps/billing/__init__.py | 0 apps/billing/apps.py | 6 ++ apps/billing/constants.py | 22 +++++ apps/billing/migrations/0001_initial.py | 109 ++++++++++++++++++++++++ apps/billing/migrations/__init__.py | 0 apps/billing/models.py | 103 ++++++++++++++++++++++ bootstrap_service/settings.py | 1 + 7 files changed, 241 insertions(+) create mode 100644 apps/billing/__init__.py create mode 100644 apps/billing/apps.py create mode 100644 apps/billing/constants.py create mode 100644 apps/billing/migrations/0001_initial.py create mode 100644 apps/billing/migrations/__init__.py create mode 100644 apps/billing/models.py diff --git a/apps/billing/__init__.py b/apps/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/billing/apps.py b/apps/billing/apps.py new file mode 100644 index 0000000..5ad805e --- /dev/null +++ b/apps/billing/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BillingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.billing" \ No newline at end of file diff --git a/apps/billing/constants.py b/apps/billing/constants.py new file mode 100644 index 0000000..afa959d --- /dev/null +++ b/apps/billing/constants.py @@ -0,0 +1,22 @@ +from django.db import models + + +class CurrencyType(models.TextChoices): + USD = "USD" + VND = "VND" + + +class BillingCycle(models.TextChoices): + MONTHLY = "monthly" + YEARLY = "yearly" + + +class FeatureValueType(models.TextChoices): + BOOLEAN = "boolean" + LIMIT = "limit" + QUOTA = "quota" + + +class UsageType(models.TextChoices): + RESOURCE = "resource" + PERIOD = "period" \ No newline at end of file diff --git a/apps/billing/migrations/0001_initial.py b/apps/billing/migrations/0001_initial.py new file mode 100644 index 0000000..f834dbb --- /dev/null +++ b/apps/billing/migrations/0001_initial.py @@ -0,0 +1,109 @@ +# Generated by Django 5.0.6 on 2026-07-07 03:57 + +import django.core.validators +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organization', '0004_alter_organization_template'), + ] + + operations = [ + migrations.CreateModel( + name='Feature', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('code', models.CharField(max_length=128, unique=True)), + ('name', models.CharField(max_length=256)), + ('description', models.TextField(blank=True)), + ('value_type', models.CharField(choices=[('boolean', 'Boolean'), ('limit', 'Limit'), ('quota', 'Quota')], max_length=16)), + ], + options={ + 'db_table': 'features', + }, + ), + migrations.CreateModel( + name='Plan', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=256)), + ('code', models.CharField(max_length=64, unique=True)), + ('price', models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), + ('description', models.TextField(blank=True)), + ('icon', models.CharField(blank=True, default='', max_length=256)), + ('support', models.TextField(blank=True)), + ('currency', models.CharField(choices=[('USD', 'Usd'), ('VND', 'Vnd')], default='USD', max_length=8)), + ('discount', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])), + ('billing_cycle', models.CharField(choices=[('monthly', 'Monthly'), ('yearly', 'Yearly')], default='monthly', max_length=16)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'db_table': 'plans', + }, + ), + migrations.CreateModel( + name='PlanFeature', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('enabled', models.BooleanField(default=True)), + ('limit_value', models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)])), + ('metadata', models.JSONField(blank=True, default=dict)), + ('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plan_features', to='billing.feature')), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plan_features', to='billing.plan')), + ], + options={ + 'db_table': 'plan_features', + }, + ), + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('period_start', models.DateTimeField()), + ('period_end', models.DateTimeField()), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='organization.organization')), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='billing.plan')), + ], + options={ + 'db_table': 'subscriptions', + }, + ), + migrations.CreateModel( + name='FeatureUsage', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('usage_type', models.CharField(choices=[('resource', 'Resource'), ('period', 'Period')], max_length=16)), + ('used_value', models.BigIntegerField(default=0)), + ('billing_period', models.DateField()), + ('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_usages', to='billing.feature')), + ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_usages', to='billing.subscription')), + ], + options={ + 'db_table': 'feature_usages', + }, + ), + migrations.AddConstraint( + model_name='planfeature', + constraint=models.UniqueConstraint(fields=('plan', 'feature'), name='unique_plan_feature'), + ), + migrations.AddConstraint( + model_name='subscription', + constraint=models.CheckConstraint(check=models.Q(('period_end__gt', models.F('period_start'))), name='subscription_period_end_after_period_start'), + ), + ] \ No newline at end of file diff --git a/apps/billing/migrations/__init__.py b/apps/billing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/billing/models.py b/apps/billing/models.py new file mode 100644 index 0000000..19a810a --- /dev/null +++ b/apps/billing/models.py @@ -0,0 +1,103 @@ +from apps.billing.constants import ( + BillingCycle, + CurrencyType, + FeatureValueType, + UsageType, +) +from apps.organization.models import Organization +from common.models.base_model import BaseModel +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + + +class Plan(BaseModel): + name = models.CharField(max_length=256) + code = models.CharField(max_length=64, unique=True) + price = models.DecimalField( + max_digits=10, decimal_places=2, default=0, validators=[MinValueValidator(0)] + ) + description = models.TextField(blank=True) + icon = models.CharField(max_length=256, blank=True, default="") + support = models.TextField(blank=True) + currency = models.CharField( + max_length=8, choices=CurrencyType.choices, default=CurrencyType.USD + ) + discount = models.IntegerField( + default=0, validators=[MinValueValidator(0), MaxValueValidator(100)] + ) + billing_cycle = models.CharField( + max_length=16, choices=BillingCycle.choices, default=BillingCycle.MONTHLY + ) + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "plans" + + +class Feature(BaseModel): + code = models.CharField(max_length=128, unique=True) + name = models.CharField(max_length=256) + description = models.TextField(blank=True) + value_type = models.CharField(max_length=16, choices=FeatureValueType.choices) + + class Meta: + db_table = "features" + + +class PlanFeature(BaseModel): + plan = models.ForeignKey( + Plan, on_delete=models.CASCADE, related_name="plan_features" + ) + feature = models.ForeignKey( + Feature, on_delete=models.CASCADE, related_name="plan_features" + ) + enabled = models.BooleanField(default=True) + limit_value = models.IntegerField( + null=True, blank=True, validators=[MinValueValidator(0)] + ) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + db_table = "plan_features" + constraints = [ + models.UniqueConstraint( + fields=["plan", "feature"], name="unique_plan_feature" + ), + ] + + +class Subscription(BaseModel): + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="subscriptions", + ) + plan = models.ForeignKey( + Plan, on_delete=models.CASCADE, related_name="subscriptions" + ) + period_start = models.DateTimeField() + period_end = models.DateTimeField() + + class Meta: + db_table = "subscriptions" + constraints = [ + models.CheckConstraint( + check=models.Q(period_end__gt=models.F("period_start")), + name="subscription_period_end_after_period_start", + ), + ] + + +class FeatureUsage(BaseModel): + subscription = models.ForeignKey( + Subscription, on_delete=models.CASCADE, related_name="feature_usages" + ) + feature = models.ForeignKey( + Feature, on_delete=models.CASCADE, related_name="feature_usages" + ) + usage_type = models.CharField(max_length=16, choices=UsageType.choices) + used_value = models.BigIntegerField(default=0, validators=[MinValueValidator(0)]) + billing_period = models.DateField() + + class Meta: + db_table = "feature_usages" \ No newline at end of file diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index f74dfbd..ef6b81b 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -64,6 +64,7 @@ "apps.custom_page", "apps.custom_email", "apps.organization_setting", + "apps.billing", "apps.organization_roles", "apps.authentication", ] From 1f6d1979d02441d902c90e2612dca5ef26b6c8d4 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 10:35:02 +0700 Subject: [PATCH 06/29] feat: implement get plans api --- apps/billing/apps.py | 2 +- apps/billing/constants.py | 2 +- apps/billing/migrations/0001_initial.py | 265 ++++++++++++++++++------ apps/billing/models.py | 9 +- apps/billing/serializers.py | 42 ++++ apps/billing/urls.py | 9 + apps/billing/views.py | 18 ++ bootstrap_service/urls.py | 1 + 8 files changed, 282 insertions(+), 66 deletions(-) create mode 100644 apps/billing/serializers.py create mode 100644 apps/billing/urls.py create mode 100644 apps/billing/views.py diff --git a/apps/billing/apps.py b/apps/billing/apps.py index 5ad805e..26831f1 100644 --- a/apps/billing/apps.py +++ b/apps/billing/apps.py @@ -3,4 +3,4 @@ class BillingConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "apps.billing" \ No newline at end of file + name = "apps.billing" diff --git a/apps/billing/constants.py b/apps/billing/constants.py index afa959d..69b8dd9 100644 --- a/apps/billing/constants.py +++ b/apps/billing/constants.py @@ -19,4 +19,4 @@ class FeatureValueType(models.TextChoices): class UsageType(models.TextChoices): RESOURCE = "resource" - PERIOD = "period" \ No newline at end of file + PERIOD = "period" diff --git a/apps/billing/migrations/0001_initial.py b/apps/billing/migrations/0001_initial.py index f834dbb..8f11d09 100644 --- a/apps/billing/migrations/0001_initial.py +++ b/apps/billing/migrations/0001_initial.py @@ -7,103 +7,248 @@ class Migration(migrations.Migration): - initial = True dependencies = [ - ('organization', '0004_alter_organization_template'), + ("organization", "0004_alter_organization_template"), ] operations = [ migrations.CreateModel( - name='Feature', + name="Feature", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('code', models.CharField(max_length=128, unique=True)), - ('name', models.CharField(max_length=256)), - ('description', models.TextField(blank=True)), - ('value_type', models.CharField(choices=[('boolean', 'Boolean'), ('limit', 'Limit'), ('quota', 'Quota')], max_length=16)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("code", models.CharField(max_length=128, unique=True)), + ("name", models.CharField(max_length=256)), + ("description", models.TextField(blank=True)), + ( + "value_type", + models.CharField( + choices=[ + ("boolean", "Boolean"), + ("limit", "Limit"), + ("quota", "Quota"), + ], + max_length=16, + ), + ), ], options={ - 'db_table': 'features', + "db_table": "features", }, ), migrations.CreateModel( - name='Plan', + name="Plan", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=256)), - ('code', models.CharField(max_length=64, unique=True)), - ('price', models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(0)])), - ('description', models.TextField(blank=True)), - ('icon', models.CharField(blank=True, default='', max_length=256)), - ('support', models.TextField(blank=True)), - ('currency', models.CharField(choices=[('USD', 'Usd'), ('VND', 'Vnd')], default='USD', max_length=8)), - ('discount', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])), - ('billing_cycle', models.CharField(choices=[('monthly', 'Monthly'), ('yearly', 'Yearly')], default='monthly', max_length=16)), - ('is_active', models.BooleanField(default=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=256)), + ("code", models.CharField(max_length=64, unique=True)), + ( + "price", + models.DecimalField( + decimal_places=2, + default=0, + max_digits=10, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ("description", models.TextField(blank=True)), + ("icon", models.CharField(blank=True, default="", max_length=256)), + ("support", models.TextField(blank=True)), + ( + "currency", + models.CharField( + choices=[("USD", "Usd"), ("VND", "Vnd")], + default="USD", + max_length=8, + ), + ), + ( + "discount", + models.IntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(100), + ], + ), + ), + ( + "billing_cycle", + models.CharField( + choices=[("monthly", "Monthly"), ("yearly", "Yearly")], + default="monthly", + max_length=16, + ), + ), + ("is_active", models.BooleanField(default=True)), ], options={ - 'db_table': 'plans', + "db_table": "plans", }, ), migrations.CreateModel( - name='PlanFeature', + name="PlanFeature", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('enabled', models.BooleanField(default=True)), - ('limit_value', models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)])), - ('metadata', models.JSONField(blank=True, default=dict)), - ('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plan_features', to='billing.feature')), - ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plan_features', to='billing.plan')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("enabled", models.BooleanField(default=True)), + ( + "limit_value", + models.IntegerField( + blank=True, + null=True, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "feature", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="plan_features", + to="billing.feature", + ), + ), + ( + "plan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="plan_features", + to="billing.plan", + ), + ), ], options={ - 'db_table': 'plan_features', + "db_table": "plan_features", }, ), migrations.CreateModel( - name='Subscription', + name="Subscription", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('period_start', models.DateTimeField()), - ('period_end', models.DateTimeField()), - ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='organization.organization')), - ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='billing.plan')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("period_start", models.DateTimeField()), + ("period_end", models.DateTimeField()), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscriptions", + to="organization.organization", + ), + ), + ( + "plan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscriptions", + to="billing.plan", + ), + ), ], options={ - 'db_table': 'subscriptions', + "db_table": "subscriptions", }, ), migrations.CreateModel( - name='FeatureUsage', + name="FeatureUsage", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('usage_type', models.CharField(choices=[('resource', 'Resource'), ('period', 'Period')], max_length=16)), - ('used_value', models.BigIntegerField(default=0)), - ('billing_period', models.DateField()), - ('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_usages', to='billing.feature')), - ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feature_usages', to='billing.subscription')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "usage_type", + models.CharField( + choices=[("resource", "Resource"), ("period", "Period")], + max_length=16, + ), + ), + ("used_value", models.BigIntegerField(default=0)), + ("billing_period", models.DateField()), + ( + "feature", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="feature_usages", + to="billing.feature", + ), + ), + ( + "subscription", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="feature_usages", + to="billing.subscription", + ), + ), ], options={ - 'db_table': 'feature_usages', + "db_table": "feature_usages", }, ), migrations.AddConstraint( - model_name='planfeature', - constraint=models.UniqueConstraint(fields=('plan', 'feature'), name='unique_plan_feature'), + model_name="planfeature", + constraint=models.UniqueConstraint( + fields=("plan", "feature"), name="unique_plan_feature" + ), ), migrations.AddConstraint( - model_name='subscription', - constraint=models.CheckConstraint(check=models.Q(('period_end__gt', models.F('period_start'))), name='subscription_period_end_after_period_start'), + model_name="subscription", + constraint=models.CheckConstraint( + check=models.Q(("period_end__gt", models.F("period_start"))), + name="subscription_period_end_after_period_start", + ), ), - ] \ No newline at end of file + ] diff --git a/apps/billing/models.py b/apps/billing/models.py index 19a810a..8be279d 100644 --- a/apps/billing/models.py +++ b/apps/billing/models.py @@ -1,3 +1,7 @@ +from common.models.base_model import BaseModel +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + from apps.billing.constants import ( BillingCycle, CurrencyType, @@ -5,9 +9,6 @@ UsageType, ) from apps.organization.models import Organization -from common.models.base_model import BaseModel -from django.core.validators import MaxValueValidator, MinValueValidator -from django.db import models class Plan(BaseModel): @@ -100,4 +101,4 @@ class FeatureUsage(BaseModel): billing_period = models.DateField() class Meta: - db_table = "feature_usages" \ No newline at end of file + db_table = "feature_usages" diff --git a/apps/billing/serializers.py b/apps/billing/serializers.py new file mode 100644 index 0000000..b5e4317 --- /dev/null +++ b/apps/billing/serializers.py @@ -0,0 +1,42 @@ +from rest_framework import serializers + +from apps.billing.models import Feature, Plan, PlanFeature + + +class FeatureSerializer(serializers.ModelSerializer): + class Meta: + model = Feature + fields = [ + "id", + "code", + "name", + "description", + "value_type", + ] + extra_kwargs = { + "id": {"read_only": True}, + } + + +class PlanFeatureSerializer(serializers.ModelSerializer): + feature = FeatureSerializer(read_only=True) + + class Meta: + model = PlanFeature + fields = [ + "id", + "feature", + "enabled", + "limit_value", + "metadata", + ] + extra_kwargs = { + "id": {"read_only": True}, + } + + +class PlanSerializer(serializers.ModelSerializer): + class Meta: + model = Plan + fields = "__all__" + extra_kwargs = {"id": {"read_only": True}} diff --git a/apps/billing/urls.py b/apps/billing/urls.py new file mode 100644 index 0000000..7d66f20 --- /dev/null +++ b/apps/billing/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from apps.billing.views import PlanListView + +app_name = "billing" + +urlpatterns = [ + path("plans", PlanListView.as_view(), name="plans"), +] diff --git a/apps/billing/views.py b/apps/billing/views.py new file mode 100644 index 0000000..e37607e --- /dev/null +++ b/apps/billing/views.py @@ -0,0 +1,18 @@ +from common.pagination.base_pagination import BasePagination +from rest_framework import generics +from rest_framework.filters import OrderingFilter + +from apps.billing.models import Plan +from apps.billing.serializers import PlanSerializer + + +class PlanListView(generics.ListAPIView): + """List active subscription plans with their feature definitions.""" + + serializer_class = PlanSerializer + pagination_class = BasePagination + filter_backends = [OrderingFilter] + ordering = ["price"] + queryset = Plan.objects.filter(is_active=True).prefetch_related( + "plan_features__feature" + ) diff --git a/bootstrap_service/urls.py b/bootstrap_service/urls.py index f764f69..f1bdb08 100644 --- a/bootstrap_service/urls.py +++ b/bootstrap_service/urls.py @@ -63,5 +63,6 @@ def health_check(_): path("api/", include("apps.custom_page.urls")), path("api/", include("apps.custom_email.urls")), path("api/", include("apps.organization_setting.urls")), + path("api/", include("apps.billing.urls")), path("api/", include("apps.organization.urls")), ] From 1a42ce9fb1035f2cfa73e71bffc0e6ca3445a083 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 11:02:24 +0700 Subject: [PATCH 07/29] feat: create default subscription on org creation --- apps/billing/apps.py | 4 ++ apps/billing/constants.py | 11 ++++++ apps/billing/migrations/0001_initial.py | 8 +++- apps/billing/services.py | 38 +++++++++++++++++++ .../management/commands/init_organization.py | 2 + 5 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 apps/billing/services.py diff --git a/apps/billing/apps.py b/apps/billing/apps.py index 26831f1..679689a 100644 --- a/apps/billing/apps.py +++ b/apps/billing/apps.py @@ -4,3 +4,7 @@ class BillingConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.billing" + + def ready(self): + # Importing tasks connects the task_failure signal handler defined there. + from apps.billing import tasks # noqa: F401 diff --git a/apps/billing/constants.py b/apps/billing/constants.py index 69b8dd9..45d96ba 100644 --- a/apps/billing/constants.py +++ b/apps/billing/constants.py @@ -1,6 +1,11 @@ from django.db import models +class PlanCodeType: + # Plan code used as the default when provisioning a new organization. + FREE = "free" + + class CurrencyType(models.TextChoices): USD = "USD" VND = "VND" @@ -20,3 +25,9 @@ class FeatureValueType(models.TextChoices): class UsageType(models.TextChoices): RESOURCE = "resource" PERIOD = "period" + + +BILLING_CYCLE_DAYS = { + BillingCycle.MONTHLY: 30, + BillingCycle.YEARLY: 365, +} diff --git a/apps/billing/migrations/0001_initial.py b/apps/billing/migrations/0001_initial.py index 8f11d09..13a8597 100644 --- a/apps/billing/migrations/0001_initial.py +++ b/apps/billing/migrations/0001_initial.py @@ -215,7 +215,13 @@ class Migration(migrations.Migration): max_length=16, ), ), - ("used_value", models.BigIntegerField(default=0)), + ( + "used_value", + models.BigIntegerField( + default=0, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), ("billing_period", models.DateField()), ( "feature", diff --git a/apps/billing/services.py b/apps/billing/services.py new file mode 100644 index 0000000..8760505 --- /dev/null +++ b/apps/billing/services.py @@ -0,0 +1,38 @@ +import logging +from datetime import timedelta + +from django.utils import timezone + +from apps.billing.constants import BILLING_CYCLE_DAYS, PlanCodeType +from apps.billing.models import Plan, Subscription + +logger = logging.getLogger(__name__) + + +def create_default_subscription(organization): + """Create the default Free subscription for a newly-created organization. + Args: + organization: The newly-created Organization. + """ + plan = Plan.objects.filter(code=PlanCodeType.FREE, is_active=True).first() + if plan is None: + logger.warning( + "Default plan '%s' not found, skipping subscription for org %s. " + "Run the billing seed migration.", + PlanCodeType.FREE, + organization.slug_name, + ) + return None + + now = timezone.now() + duration_days = BILLING_CYCLE_DAYS.get(plan.billing_cycle, 30) + period_end = now + timedelta(days=duration_days) + + subscription = Subscription.objects.create( + organization=organization, + plan=plan, + period_start=now, + period_end=period_end, + ) + + return subscription diff --git a/bootstrap_service/management/commands/init_organization.py b/bootstrap_service/management/commands/init_organization.py index 244a646..2daa1a6 100644 --- a/bootstrap_service/management/commands/init_organization.py +++ b/bootstrap_service/management/commands/init_organization.py @@ -24,6 +24,7 @@ from django.utils import timezone from apps.authentication.models import RootUser +from apps.billing.services import create_default_subscription from apps.custom_email.service import create_default_organization_email from apps.custom_page.service import create_default_pages from apps.organization.models import Organization @@ -124,6 +125,7 @@ def _create_organization_with_roles(self, org_id, org_name, org_slug, result): create_default_pages(organization) create_default_organization_email(organization) create_default_organization_setting(organization) + create_default_subscription(organization) self.stdout.write(self.style.SUCCESS("Created default policies")) role_mappings = [ From a4e4a9d1f246dd41c11512c4b3e76449dacd620d Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 11:10:27 +0700 Subject: [PATCH 08/29] feat: update the org check api to specify plan type --- apps/organization/views.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/organization/views.py b/apps/organization/views.py index b2d0c29..7304f80 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -1,3 +1,5 @@ +from datetime import timezone + from common.pagination.base_pagination import BasePagination from django.db.models import CharField, Count, Subquery from django.shortcuts import get_object_or_404 @@ -77,6 +79,15 @@ def get(self, request, slug_name): ) .first() ) + subscription = ( + organization.subscriptions.filter( + period_start__lte=timezone.now(), + period_end__gte=timezone.now(), + ) + .select_related("plan") + .order_by("-created_at") + .first() + ) return Response( { @@ -85,6 +96,14 @@ def get(self, request, slug_name): "setting": OrganizationSettingsWithCustomPagesSerializer(setting).data if setting else None, + "plan": ( + { + "code": subscription.plan.code, + "name": subscription.plan.name, + } + if subscription + else None + ), }, status=status.HTTP_200_OK, ) From 7f6a99d09de04b14f561c6bef88ce5b64d9cb912 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 11:24:37 +0700 Subject: [PATCH 09/29] feat: seed default plans, features --- .../0002_seed_default_plans_and_features.py | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 apps/billing/migrations/0002_seed_default_plans_and_features.py diff --git a/apps/billing/migrations/0002_seed_default_plans_and_features.py b/apps/billing/migrations/0002_seed_default_plans_and_features.py new file mode 100644 index 0000000..e32e7dd --- /dev/null +++ b/apps/billing/migrations/0002_seed_default_plans_and_features.py @@ -0,0 +1,206 @@ +"""Seed default Free and Pro plans with their feature definitions.""" + +from django.db import migrations + +# Plan codes +FREE_PLAN_CODE = "free" +PRO_PLAN_CODE = "pro" +PRO_PLAN_MONTHLY_CODE = PRO_PLAN_CODE + "_monthly" +PRO_PLAN_YEARLY_CODE = PRO_PLAN_CODE + "_yearly" + +# Plan specs +PLANS = [ + { + "code": FREE_PLAN_CODE, + "name": "Free", + "price": 0, + "description": "Free plan", + "currency": "USD", + "billing_cycle": "monthly", + }, + { + "code": PRO_PLAN_MONTHLY_CODE, + "name": "Pro", + "price": 89, + "description": "Pro plan", + "currency": "USD", + "billing_cycle": "monthly", + }, + { + "code": PRO_PLAN_YEARLY_CODE, + "name": "Pro", + "price": 89 * 12, + "description": "Pro plan", + "currency": "USD", + "billing_cycle": "yearly", + }, +] + +# Feature catalog (9 features). value_type drives the PlanFeature shape: +# limit -> limit_value is the numeric cap (null = unlimited) +# quota -> limit_value is the quota amount +# boolean -> limit_value is null; use `enabled` +FEATURES = [ + {"code": "device.max_count", "name": "Max devices", "value_type": "limit"}, + {"code": "space.max_count", "name": "Max spaces", "value_type": "limit"}, + {"code": "dashboard.max_count", "name": "Max dashboards", "value_type": "limit"}, + { + "code": "dashboard.basic_widgets", + "name": "Basic widgets", + "value_type": "boolean", + }, + { + "code": "dashboard.custom_charts", + "name": "Custom charts & maps", + "value_type": "boolean", + }, + {"code": "map_view.2d", "name": "2D map view", "value_type": "boolean"}, + {"code": "map_view.3d", "name": "3D map view", "value_type": "boolean"}, + { + "code": "whitelabel.enabled", + "name": "White-label branding", + "value_type": "boolean", + }, + { + "code": "data_retention.days", + "name": "Data retention (days)", + "value_type": "quota", + }, + { + "code": "support.onboarding_video", + "name": "Onboarding video", + "value_type": "boolean", + }, + {"code": "support.email", "name": "Email support", "value_type": "boolean"}, + { + "code": "support.email_community", + "name": "Email, community support", + "value_type": "boolean", + }, + {"code": "support.priority", "name": "Priority support", "value_type": "boolean"}, + { + "code": "support.fully_maintenance", + "name": "Fully maintenance", + "value_type": "boolean", + }, +] + +# Per-plan feature values. Keys are feature codes; +FREE_FEATURES = { + "device.max_count": {"enabled": True, "limit_value": 10}, + "space.max_count": {"enabled": True, "limit_value": 1}, + "dashboard.max_count": {"enabled": True, "limit_value": 1}, + "dashboard.basic_widgets": {"enabled": True, "limit_value": None}, + "dashboard.custom_charts": {"enabled": False, "limit_value": None}, + "map_view.2d": {"enabled": True, "limit_value": None}, + "map_view.3d": {"enabled": False, "limit_value": None}, + "whitelabel.enabled": {"enabled": False, "limit_value": None}, + "data_retention.days": {"enabled": True, "limit_value": 7}, + "support.onboarding_video": {"enabled": True, "limit_value": None}, + "support.email": {"enabled": True, "limit_value": None}, + "support.email_community": {"enabled": False, "limit_value": None}, + "support.priority": {"enabled": False, "limit_value": None}, + "support.fully_maintenance": {"enabled": False, "limit_value": None}, +} + +PRO_FEATURES = { + "device.max_count": {"enabled": True, "limit_value": 100}, + "space.max_count": {"enabled": True, "limit_value": None}, + "dashboard.max_count": {"enabled": True, "limit_value": None}, + "dashboard.basic_widgets": {"enabled": True, "limit_value": None}, + "dashboard.custom_charts": {"enabled": True, "limit_value": None}, + "map_view.2d": {"enabled": True, "limit_value": None}, + "map_view.3d": {"enabled": True, "limit_value": None}, + "whitelabel.enabled": {"enabled": True, "limit_value": None}, + "data_retention.days": {"enabled": True, "limit_value": 180}, + "support.onboarding_video": {"enabled": True, "limit_value": None}, + "support.email": {"enabled": True, "limit_value": None}, + "support.email_community": {"enabled": True, "limit_value": None}, + "support.priority": {"enabled": True, "limit_value": None}, + "support.fully_maintenance": {"enabled": True, "limit_value": None}, +} + +PLAN_FEATURE_VALUES = { + FREE_PLAN_CODE: FREE_FEATURES, + PRO_PLAN_MONTHLY_CODE: PRO_FEATURES, + PRO_PLAN_YEARLY_CODE: PRO_FEATURES, +} + +FEATURE_CODES = [f["code"] for f in FEATURES] +PLAN_CODES = [p["code"] for p in PLANS] + + +def _upsert_plans(apps): + Plan = apps.get_model("billing", "Plan") + plans_by_code = {} + for spec in PLANS: + plan, _ = Plan.objects.update_or_create( + code=spec["code"], + defaults={**{k: v for k, v in spec.items() if k != "code"}}, + ) + plans_by_code[spec["code"]] = plan + return plans_by_code + + +def _upsert_features(apps): + Feature = apps.get_model("billing", "Feature") + features_by_code = {} + for spec in FEATURES: + feature, _ = Feature.objects.update_or_create( + code=spec["code"], + defaults={ + "name": spec["name"], + "description": "", + "value_type": spec["value_type"], + }, + ) + features_by_code[spec["code"]] = feature + return features_by_code + + +def _upsert_plan_features(apps, plans_by_code, features_by_code): + PlanFeature = apps.get_model("billing", "PlanFeature") + for plan_code, plan in plans_by_code.items(): + values = PLAN_FEATURE_VALUES.get(plan_code, {}) + for feature_code, feature in features_by_code.items(): + v = values.get(feature_code, {"enabled": False, "limit_value": None}) + PlanFeature.objects.update_or_create( + plan=plan, + feature=feature, + defaults={ + "enabled": v["enabled"], + "limit_value": v["limit_value"], + }, + ) + + +def forward(apps, schema_editor): + plans_by_code = _upsert_plans(apps) + features_by_code = _upsert_features(apps) + _upsert_plan_features(apps, plans_by_code, features_by_code) + + +def reverse(apps, schema_editor): + Plan = apps.get_model("billing", "Plan") + Subscription = apps.get_model("billing", "Subscription") + if Subscription.objects.filter(plan__code__in=PLAN_CODES).exists(): + raise RuntimeError( + "Cannot reverse migration: active subscriptions exist for these plans." + ) + PlanFeature = apps.get_model("billing", "PlanFeature") + Feature = apps.get_model("billing", "Feature") + # Delete seeded PlanFeatures, Plans, and Features by code. + PlanFeature.objects.filter( + plan__code__in=PLAN_CODES, + feature__code__in=FEATURE_CODES, + ).delete() + Plan.objects.filter(code__in=PLAN_CODES).delete() + Feature.objects.filter(code__in=FEATURE_CODES).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("billing", "0001_initial"), + ] + + operations = [migrations.RunPython(forward, reverse)] From 7f015b0001921b7fb669f579c4b783f9bc8efcc9 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 11:39:56 +0700 Subject: [PATCH 10/29] chore: update plan model --- apps/billing/migrations/0001_initial.py | 49 +++++++++-- .../0002_seed_default_plans_and_features.py | 83 +++++++++++++------ apps/billing/models.py | 25 ++++-- apps/billing/serializers.py | 64 +++++++++++++- apps/billing/services.py | 38 --------- apps/billing/services/__init__.py | 4 + apps/billing/services/subscription.py | 55 ++++++++++++ apps/billing/views.py | 16 +++- apps/organization/views.py | 13 ++- .../management/commands/init_organization.py | 2 +- 10 files changed, 264 insertions(+), 85 deletions(-) delete mode 100644 apps/billing/services.py create mode 100644 apps/billing/services/__init__.py create mode 100644 apps/billing/services/subscription.py diff --git a/apps/billing/migrations/0001_initial.py b/apps/billing/migrations/0001_initial.py index 13a8597..669dfa4 100644 --- a/apps/billing/migrations/0001_initial.py +++ b/apps/billing/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.6 on 2026-07-07 03:57 +# Generated by Django 5.0.6 on 2026-07-08 08:19 import django.core.validators import django.db.models.deletion @@ -65,6 +65,27 @@ class Migration(migrations.Migration): ("updated_at", models.DateTimeField(auto_now=True)), ("name", models.CharField(max_length=256)), ("code", models.CharField(max_length=64, unique=True)), + ("description", models.TextField(blank=True)), + ], + options={ + "db_table": "plans", + }, + ), + migrations.CreateModel( + name="PlanItem", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ( "price", models.DecimalField( @@ -74,9 +95,7 @@ class Migration(migrations.Migration): validators=[django.core.validators.MinValueValidator(0)], ), ), - ("description", models.TextField(blank=True)), ("icon", models.CharField(blank=True, default="", max_length=256)), - ("support", models.TextField(blank=True)), ( "currency", models.CharField( @@ -104,9 +123,17 @@ class Migration(migrations.Migration): ), ), ("is_active", models.BooleanField(default=True)), + ( + "plan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="plan_items", + to="billing.plan", + ), + ), ], options={ - "db_table": "plans", + "db_table": "plan_items", }, ), migrations.CreateModel( @@ -181,11 +208,13 @@ class Migration(migrations.Migration): ), ), ( - "plan", + "plan_item", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, related_name="subscriptions", - to="billing.plan", + to="billing.planitem", ), ), ], @@ -250,6 +279,12 @@ class Migration(migrations.Migration): fields=("plan", "feature"), name="unique_plan_feature" ), ), + migrations.AddConstraint( + model_name="planitem", + constraint=models.UniqueConstraint( + fields=("plan", "billing_cycle"), name="unique_plan_billing_cycle" + ), + ), migrations.AddConstraint( model_name="subscription", constraint=models.CheckConstraint( diff --git a/apps/billing/migrations/0002_seed_default_plans_and_features.py b/apps/billing/migrations/0002_seed_default_plans_and_features.py index e32e7dd..7f3fb84 100644 --- a/apps/billing/migrations/0002_seed_default_plans_and_features.py +++ b/apps/billing/migrations/0002_seed_default_plans_and_features.py @@ -5,32 +5,46 @@ # Plan codes FREE_PLAN_CODE = "free" PRO_PLAN_CODE = "pro" -PRO_PLAN_MONTHLY_CODE = PRO_PLAN_CODE + "_monthly" -PRO_PLAN_YEARLY_CODE = PRO_PLAN_CODE + "_yearly" -# Plan specs PLANS = [ { "code": FREE_PLAN_CODE, "name": "Free", + "description": "First 10 devices free.\n1 week Data Retention", + }, + { + "code": PRO_PLAN_CODE, + "name": "Pro", + "description": "Up to 100 devices\n6 months Data Retention", + }, +] + +PLAN_ITEMS = [ + { + "plan_code": FREE_PLAN_CODE, "price": 0, - "description": "Free plan", "currency": "USD", + "discount": 0, "billing_cycle": "monthly", }, { - "code": PRO_PLAN_MONTHLY_CODE, - "name": "Pro", - "price": 89, - "description": "Pro plan", + "plan_code": FREE_PLAN_CODE, + "price": 0, "currency": "USD", + "discount": 0, + "billing_cycle": "yearly", + }, + { + "plan_code": PRO_PLAN_CODE, + "price": 99, + "currency": "USD", + "discount": 10, "billing_cycle": "monthly", }, { - "code": PRO_PLAN_YEARLY_CODE, - "name": "Pro", - "price": 89 * 12, - "description": "Pro plan", + "plan_code": PRO_PLAN_CODE, + "price": 99 * 12, + "discount": 10, "currency": "USD", "billing_cycle": "yearly", }, @@ -41,9 +55,9 @@ # quota -> limit_value is the quota amount # boolean -> limit_value is null; use `enabled` FEATURES = [ - {"code": "device.max_count", "name": "Max devices", "value_type": "limit"}, - {"code": "space.max_count", "name": "Max spaces", "value_type": "limit"}, - {"code": "dashboard.max_count", "name": "Max dashboards", "value_type": "limit"}, + {"code": "device.max_count", "name": "Device(s)", "value_type": "limit"}, + {"code": "space.max_count", "name": "Space(s)", "value_type": "limit"}, + {"code": "dashboard.max_count", "name": "Dashboard(s)", "value_type": "limit"}, { "code": "dashboard.basic_widgets", "name": "Basic widgets", @@ -91,16 +105,11 @@ "space.max_count": {"enabled": True, "limit_value": 1}, "dashboard.max_count": {"enabled": True, "limit_value": 1}, "dashboard.basic_widgets": {"enabled": True, "limit_value": None}, - "dashboard.custom_charts": {"enabled": False, "limit_value": None}, "map_view.2d": {"enabled": True, "limit_value": None}, - "map_view.3d": {"enabled": False, "limit_value": None}, - "whitelabel.enabled": {"enabled": False, "limit_value": None}, + "map_view.3d": {"enabled": True, "limit_value": None}, "data_retention.days": {"enabled": True, "limit_value": 7}, "support.onboarding_video": {"enabled": True, "limit_value": None}, "support.email": {"enabled": True, "limit_value": None}, - "support.email_community": {"enabled": False, "limit_value": None}, - "support.priority": {"enabled": False, "limit_value": None}, - "support.fully_maintenance": {"enabled": False, "limit_value": None}, } PRO_FEATURES = { @@ -122,8 +131,7 @@ PLAN_FEATURE_VALUES = { FREE_PLAN_CODE: FREE_FEATURES, - PRO_PLAN_MONTHLY_CODE: PRO_FEATURES, - PRO_PLAN_YEARLY_CODE: PRO_FEATURES, + PRO_PLAN_CODE: PRO_FEATURES, } FEATURE_CODES = [f["code"] for f in FEATURES] @@ -136,12 +144,30 @@ def _upsert_plans(apps): for spec in PLANS: plan, _ = Plan.objects.update_or_create( code=spec["code"], - defaults={**{k: v for k, v in spec.items() if k != "code"}}, + defaults={ + "name": spec["name"], + "description": spec["description"], + }, ) plans_by_code[spec["code"]] = plan return plans_by_code +def _upsert_plan_items(apps, plans_by_code): + PlanItem = apps.get_model("billing", "PlanItem") + for spec in PLAN_ITEMS: + PlanItem.objects.update_or_create( + plan=plans_by_code[spec["plan_code"]], + billing_cycle=spec["billing_cycle"], + defaults={ + "price": spec["price"], + "discount": spec["discount"], + "currency": spec["currency"], + "is_active": True, + }, + ) + + def _upsert_features(apps): Feature = apps.get_model("billing", "Feature") features_by_code = {} @@ -176,14 +202,16 @@ def _upsert_plan_features(apps, plans_by_code, features_by_code): def forward(apps, schema_editor): plans_by_code = _upsert_plans(apps) + _upsert_plan_items(apps, plans_by_code) features_by_code = _upsert_features(apps) _upsert_plan_features(apps, plans_by_code, features_by_code) def reverse(apps, schema_editor): Plan = apps.get_model("billing", "Plan") + PlanItem = apps.get_model("billing", "PlanItem") Subscription = apps.get_model("billing", "Subscription") - if Subscription.objects.filter(plan__code__in=PLAN_CODES).exists(): + if Subscription.objects.filter(plan_item__plan__code__in=PLAN_CODES).exists(): raise RuntimeError( "Cannot reverse migration: active subscriptions exist for these plans." ) @@ -194,6 +222,11 @@ def reverse(apps, schema_editor): plan__code__in=PLAN_CODES, feature__code__in=FEATURE_CODES, ).delete() + for spec in PLAN_ITEMS: + PlanItem.objects.filter( + plan__code=spec["plan_code"], + billing_cycle=spec["billing_cycle"], + ).delete() Plan.objects.filter(code__in=PLAN_CODES).delete() Feature.objects.filter(code__in=FEATURE_CODES).delete() diff --git a/apps/billing/models.py b/apps/billing/models.py index 8be279d..3811399 100644 --- a/apps/billing/models.py +++ b/apps/billing/models.py @@ -14,12 +14,18 @@ class Plan(BaseModel): name = models.CharField(max_length=256) code = models.CharField(max_length=64, unique=True) + description = models.TextField(blank=True) + + class Meta: + db_table = "plans" + + +class PlanItem(BaseModel): + plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name="plan_items") price = models.DecimalField( max_digits=10, decimal_places=2, default=0, validators=[MinValueValidator(0)] ) - description = models.TextField(blank=True) icon = models.CharField(max_length=256, blank=True, default="") - support = models.TextField(blank=True) currency = models.CharField( max_length=8, choices=CurrencyType.choices, default=CurrencyType.USD ) @@ -32,7 +38,12 @@ class Plan(BaseModel): is_active = models.BooleanField(default=True) class Meta: - db_table = "plans" + db_table = "plan_items" + constraints = [ + models.UniqueConstraint( + fields=["plan", "billing_cycle"], name="unique_plan_billing_cycle" + ), + ] class Feature(BaseModel): @@ -73,8 +84,12 @@ class Subscription(BaseModel): on_delete=models.CASCADE, related_name="subscriptions", ) - plan = models.ForeignKey( - Plan, on_delete=models.CASCADE, related_name="subscriptions" + plan_item = models.ForeignKey( + PlanItem, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="subscriptions", ) period_start = models.DateTimeField() period_end = models.DateTimeField() diff --git a/apps/billing/serializers.py b/apps/billing/serializers.py index b5e4317..36f573b 100644 --- a/apps/billing/serializers.py +++ b/apps/billing/serializers.py @@ -1,6 +1,17 @@ from rest_framework import serializers -from apps.billing.models import Feature, Plan, PlanFeature +from apps.billing.models import Feature, Plan, PlanFeature, PlanItem + + +class ChargebeeWebhookInputSerializer(serializers.Serializer): + id = serializers.CharField( + max_length=64, + help_text="Chargebee event ID.", + ) + event_type = serializers.CharField(max_length=128) + occurred_at = serializers.IntegerField() + content = serializers.DictField() + source = serializers.CharField(max_length=64, required=False) class FeatureSerializer(serializers.ModelSerializer): @@ -35,8 +46,57 @@ class Meta: } +class PlanItemSerializer(serializers.ModelSerializer): + class Meta: + model = PlanItem + fields = [ + "id", + "price", + "icon", + "currency", + "discount", + "billing_cycle", + "is_active", + "created_at", + "updated_at", + ] + extra_kwargs = {"id": {"read_only": True}} + + class PlanSerializer(serializers.ModelSerializer): + plan_items = PlanItemSerializer(many=True, read_only=True) + class Meta: model = Plan - fields = "__all__" + fields = [ + "id", + "name", + "code", + "description", + "plan_items", + "created_at", + "updated_at", + ] extra_kwargs = {"id": {"read_only": True}} + + +class PlanWithFeaturesSerializer(PlanSerializer): + """Plan with nested feature definitions — for plan comparison views.""" + + def to_representation(self, instance): + data = super().to_representation(instance) + plan_features = instance.plan_features.all() + features = [] + support = [] + for plan_feature in plan_features: + if plan_feature.feature.code.startswith("support."): + support.append(plan_feature) + else: + features.append(plan_feature) + + data["features"] = PlanFeatureSerializer(features, many=True).data + data["support"] = PlanFeatureSerializer(support, many=True).data + return data + + class Meta(PlanSerializer.Meta): + fields = list(PlanSerializer.Meta.fields) diff --git a/apps/billing/services.py b/apps/billing/services.py deleted file mode 100644 index 8760505..0000000 --- a/apps/billing/services.py +++ /dev/null @@ -1,38 +0,0 @@ -import logging -from datetime import timedelta - -from django.utils import timezone - -from apps.billing.constants import BILLING_CYCLE_DAYS, PlanCodeType -from apps.billing.models import Plan, Subscription - -logger = logging.getLogger(__name__) - - -def create_default_subscription(organization): - """Create the default Free subscription for a newly-created organization. - Args: - organization: The newly-created Organization. - """ - plan = Plan.objects.filter(code=PlanCodeType.FREE, is_active=True).first() - if plan is None: - logger.warning( - "Default plan '%s' not found, skipping subscription for org %s. " - "Run the billing seed migration.", - PlanCodeType.FREE, - organization.slug_name, - ) - return None - - now = timezone.now() - duration_days = BILLING_CYCLE_DAYS.get(plan.billing_cycle, 30) - period_end = now + timedelta(days=duration_days) - - subscription = Subscription.objects.create( - organization=organization, - plan=plan, - period_start=now, - period_end=period_end, - ) - - return subscription diff --git a/apps/billing/services/__init__.py b/apps/billing/services/__init__.py new file mode 100644 index 0000000..fc68166 --- /dev/null +++ b/apps/billing/services/__init__.py @@ -0,0 +1,4 @@ +from apps.billing.services.chargebee_webhook import process_chargebee_event +from apps.billing.services.subscription import create_default_subscription + +__all__ = ["create_default_subscription", "process_chargebee_event"] diff --git a/apps/billing/services/subscription.py b/apps/billing/services/subscription.py new file mode 100644 index 0000000..fb1574c --- /dev/null +++ b/apps/billing/services/subscription.py @@ -0,0 +1,55 @@ +import logging +from datetime import timedelta + +from django.utils import timezone + +from apps.billing.constants import BILLING_CYCLE_DAYS, BillingCycle, PlanCodeType +from apps.billing.models import PlanItem, Subscription + +logger = logging.getLogger(__name__) + + +def _chargebee_item_price_id(plan_item): + return f"{plan_item.plan.code}_{plan_item.billing_cycle}" + + +def create_default_subscription(organization, owner=None): + """Create the default Free subscription for a newly-created organization. + + Idempotent-safe: returns None (without raising) if the Free plan has not + been seeded yet, so organization creation never fails because of billing + setup. + + Args: + organization: The newly-created Organization. + """ + plan_item = ( + PlanItem.objects.select_related("plan") + .filter( + plan__code=PlanCodeType.FREE, + billing_cycle=BillingCycle.MONTHLY, + is_active=True, + ) + .first() + ) + if plan_item is None: + logger.warning( + "Default plan item '%s' not found, skipping subscription for org %s. " + "Run the billing seed migration.", + PlanCodeType.FREE, + organization.slug_name, + ) + return None + + now = timezone.now() + duration_days = BILLING_CYCLE_DAYS.get(plan_item.billing_cycle, 30) + period_end = now + timedelta(days=duration_days) + + subscription = Subscription.objects.create( + organization=organization, + plan_item=plan_item, + period_start=now, + period_end=period_end, + ) + + return subscription diff --git a/apps/billing/views.py b/apps/billing/views.py index e37607e..6076e38 100644 --- a/apps/billing/views.py +++ b/apps/billing/views.py @@ -1,8 +1,9 @@ from common.pagination.base_pagination import BasePagination +from django.db.models import Min, Prefetch from rest_framework import generics from rest_framework.filters import OrderingFilter -from apps.billing.models import Plan +from apps.billing.models import Plan, PlanItem from apps.billing.serializers import PlanSerializer @@ -13,6 +14,15 @@ class PlanListView(generics.ListAPIView): pagination_class = BasePagination filter_backends = [OrderingFilter] ordering = ["price"] - queryset = Plan.objects.filter(is_active=True).prefetch_related( - "plan_features__feature" + ordering_fields = ["code", "name", "price"] + queryset = ( + Plan.objects.filter(plan_items__is_active=True) + .annotate(price=Min("plan_items__price")) + .prefetch_related( + Prefetch( + "plan_items", + queryset=PlanItem.objects.filter(is_active=True).order_by("price"), + ), + "plan_features__feature", + ) ) diff --git a/apps/organization/views.py b/apps/organization/views.py index 7304f80..973fde3 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -84,10 +84,15 @@ def get(self, request, slug_name): period_start__lte=timezone.now(), period_end__gte=timezone.now(), ) - .select_related("plan") + .select_related("plan_item__plan") .order_by("-created_at") .first() ) + plan = ( + subscription.plan_item.plan + if subscription and subscription.plan_item + else None + ) return Response( { @@ -98,10 +103,10 @@ def get(self, request, slug_name): else None, "plan": ( { - "code": subscription.plan.code, - "name": subscription.plan.name, + "code": plan.code, + "name": plan.name, } - if subscription + if plan else None ), }, diff --git a/bootstrap_service/management/commands/init_organization.py b/bootstrap_service/management/commands/init_organization.py index 2daa1a6..f77945b 100644 --- a/bootstrap_service/management/commands/init_organization.py +++ b/bootstrap_service/management/commands/init_organization.py @@ -24,7 +24,7 @@ from django.utils import timezone from apps.authentication.models import RootUser -from apps.billing.services import create_default_subscription +from apps.billing.services.subscription import create_default_subscription from apps.custom_email.service import create_default_organization_email from apps.custom_page.service import create_default_pages from apps.organization.models import Organization From 00b5e49f392494b25c48d6e18a222b0e1c4b3a3b Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 11:48:53 +0700 Subject: [PATCH 11/29] fix: add plan field in the org response --- apps/organization/serializers.py | 11 +++++++++++ apps/organization/views.py | 26 +++++++------------------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/apps/organization/serializers.py b/apps/organization/serializers.py index 3174db9..30cab0a 100644 --- a/apps/organization/serializers.py +++ b/apps/organization/serializers.py @@ -4,6 +4,7 @@ from django.conf import settings from rest_framework import serializers +from apps.billing.constants import PlanCodeType from apps.organization.models import Organization @@ -42,6 +43,16 @@ def update(self, instance, validated_data): def to_representation(self, instance): data = super().to_representation(instance) + subscription = ( + instance.subscriptions.select_related("plan_item__plan") + .order_by("-created_at") + .first() + ) + data["plan"] = ( + subscription.plan_item.plan.code + if subscription and subscription.plan_item and subscription.plan_item.plan + else PlanCodeType.FREE + ) if instance.logo and instance.logo not in ["", None]: data["url_logo"] = get_presigned_url( settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"), diff --git a/apps/organization/views.py b/apps/organization/views.py index 973fde3..a7b844d 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -1,5 +1,3 @@ -from datetime import timezone - from common.pagination.base_pagination import BasePagination from django.db.models import CharField, Count, Subquery from django.shortcuts import get_object_or_404 @@ -7,6 +5,7 @@ from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.response import Response +from apps.billing.constants import PlanCodeType from apps.organization.models import Organization from apps.organization.serializers import OrganizationSerializer from apps.organization.services import get_owner_name_query_set @@ -80,18 +79,14 @@ def get(self, request, slug_name): .first() ) subscription = ( - organization.subscriptions.filter( - period_start__lte=timezone.now(), - period_end__gte=timezone.now(), - ) - .select_related("plan_item__plan") + organization.subscriptions.select_related("plan_item__plan") .order_by("-created_at") .first() ) - plan = ( - subscription.plan_item.plan - if subscription and subscription.plan_item - else None + plan_code = ( + subscription.plan_item.plan.code + if subscription and subscription.plan_item and subscription.plan_item.plan + else PlanCodeType.FREE ) return Response( @@ -101,14 +96,7 @@ def get(self, request, slug_name): "setting": OrganizationSettingsWithCustomPagesSerializer(setting).data if setting else None, - "plan": ( - { - "code": plan.code, - "name": plan.name, - } - if plan - else None - ), + "plan": plan_code, }, status=status.HTTP_200_OK, ) From 17f2f4a1cce40577bf97afc36fd62b9806709b99 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 11:56:00 +0700 Subject: [PATCH 12/29] fix: add is_current_plan field for api get plan --- apps/billing/serializers.py | 2 ++ apps/billing/views.py | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/billing/serializers.py b/apps/billing/serializers.py index 36f573b..eb7000b 100644 --- a/apps/billing/serializers.py +++ b/apps/billing/serializers.py @@ -65,6 +65,7 @@ class Meta: class PlanSerializer(serializers.ModelSerializer): plan_items = PlanItemSerializer(many=True, read_only=True) + is_current_plan = serializers.BooleanField(read_only=True) class Meta: model = Plan @@ -74,6 +75,7 @@ class Meta: "code", "description", "plan_items", + "is_current_plan", "created_at", "updated_at", ] diff --git a/apps/billing/views.py b/apps/billing/views.py index 6076e38..90184f8 100644 --- a/apps/billing/views.py +++ b/apps/billing/views.py @@ -1,10 +1,12 @@ from common.pagination.base_pagination import BasePagination -from django.db.models import Min, Prefetch +from django.db.models import BooleanField, Case, Min, Prefetch, Value, When from rest_framework import generics from rest_framework.filters import OrderingFilter +from apps.billing.constants import PlanCodeType from apps.billing.models import Plan, PlanItem from apps.billing.serializers import PlanSerializer +from apps.organization.models import Organization class PlanListView(generics.ListAPIView): @@ -26,3 +28,24 @@ class PlanListView(generics.ListAPIView): "plan_features__feature", ) ) + + def get_queryset(self): + queryset = super().get_queryset() + organization_slug = self.request.headers.get("X-Organization") + if not organization_slug: + return queryset + + current_plan_code = ( + Organization.objects.filter(slug_name=organization_slug) + .values_list("subscriptions__plan_item__plan__code", flat=True) + .order_by("-subscriptions__created_at") + .first() + or PlanCodeType.FREE + ) + return queryset.annotate( + is_current_plan=Case( + When(code=current_plan_code, then=Value(True)), + default=Value(False), + output_field=BooleanField(), + ) + ) From 5481af8600cc1ff1772f6eda9cd99ddae4ef49a2 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 13:16:16 +0700 Subject: [PATCH 13/29] feat: plan limit enforcement --- apps/billing/serializers.py | 34 +++-- apps/billing/services/subscription.py | 173 +++++++++++++++++++++++++- apps/billing/urls.py | 18 ++- apps/billing/views.py | 139 +++++++++++++++++---- 4 files changed, 325 insertions(+), 39 deletions(-) diff --git a/apps/billing/serializers.py b/apps/billing/serializers.py index eb7000b..d1f87d2 100644 --- a/apps/billing/serializers.py +++ b/apps/billing/serializers.py @@ -3,17 +3,6 @@ from apps.billing.models import Feature, Plan, PlanFeature, PlanItem -class ChargebeeWebhookInputSerializer(serializers.Serializer): - id = serializers.CharField( - max_length=64, - help_text="Chargebee event ID.", - ) - event_type = serializers.CharField(max_length=128) - occurred_at = serializers.IntegerField() - content = serializers.DictField() - source = serializers.CharField(max_length=64, required=False) - - class FeatureSerializer(serializers.ModelSerializer): class Meta: model = Feature @@ -101,4 +90,25 @@ def to_representation(self, instance): return data class Meta(PlanSerializer.Meta): - fields = list(PlanSerializer.Meta.fields) + fields = list(PlanSerializer.Meta.fields) + ["plan_features"] + + +class ReserveQuotaSerializer(serializers.Serializer): + organization = serializers.CharField() + feature = serializers.ListField( + child=serializers.CharField(), + allow_empty=False, + ) + amount = serializers.IntegerField( + default=1, + min_value=0, + required=False, + ) + + +class ViewQuotaSerializer(serializers.Serializer): + organization = serializers.CharField() + feature = serializers.ListField( + child=serializers.CharField(), + allow_empty=False, + ) diff --git a/apps/billing/services/subscription.py b/apps/billing/services/subscription.py index fb1574c..adcb809 100644 --- a/apps/billing/services/subscription.py +++ b/apps/billing/services/subscription.py @@ -1,10 +1,18 @@ import logging from datetime import timedelta +from django.db import transaction +from django.db.models import F from django.utils import timezone from apps.billing.constants import BILLING_CYCLE_DAYS, BillingCycle, PlanCodeType -from apps.billing.models import PlanItem, Subscription +from apps.billing.models import ( + Feature, + FeatureUsage, + PlanFeature, + PlanItem, + Subscription, +) logger = logging.getLogger(__name__) @@ -53,3 +61,166 @@ def create_default_subscription(organization, owner=None): ) return subscription + + +def _get_quota_meta(organization, feature_code): + """Resolve quota metadata for an org+feature. + Returns (subscription_id, feature_id, limit_value, is_allowed). + """ + subscription = ( + Subscription.objects.select_related("plan_item__plan") + .filter(organization=organization) + .order_by("-created_at") + .first() + ) + if subscription is None or subscription.plan_item_id is None: + return None, None, None, False + + feature = Feature.objects.filter(code=feature_code).first() + if feature is None: + return None, None, None, False + + plan_feature = PlanFeature.objects.filter( + plan=subscription.plan_item.plan, + feature=feature, + enabled=True, + ).first() + if plan_feature is None: + return subscription.id, feature.id, None, False + + return subscription.id, feature.id, plan_feature.limit_value, True + + +def reserve_quota(organization, feature_code, amount=1): + """Atomically reserve ``amount`` of a feature for the org's current period. + Returns ``(reserved: bool, error: str | None)``. Limited features increment + ``FeatureUsage.used_value`` atomically. Unlimited features are allowed + without tracking usage. + If the org's current plan does not include the feature, returns + ``(False, error)``. + """ + try: + subscription_id, feature_id, limit_value, is_allowed = _get_quota_meta( + organization, feature_code + ) + if not is_allowed: + return False, f"Feature '{feature_code}' is not allowed for current plan." + if subscription_id is None or feature_id is None: + return False, f"Feature '{feature_code}' is not available." + if amount == 0 or limit_value is None: + return True, None + + with transaction.atomic(): + period = timezone.now().date().replace(day=1) + usage, _ = FeatureUsage.objects.select_for_update().get_or_create( + subscription_id=subscription_id, + feature_id=feature_id, + billing_period=period, + defaults={ + "usage_type": "resource", + "used_value": 0, + }, + ) + + if usage.used_value + amount > limit_value: + return False, ( + f"Quota exceeded for '{feature_code}' " + f"(used {usage.used_value}/{limit_value})." + ) + + usage.used_value = F("used_value") + amount + usage.save(update_fields=["used_value"]) + return True, None + except Exception as e: # noqa: BLE001 + logger.error( + "reserve_quota failed for %s/%s: %s", + organization.slug_name, + feature_code, + e, + ) + return False, "Unable to reserve quota." + + +def reserve_quotas(organization, feature_codes, amount=1): + reserved_features = [] + + for feature_code in feature_codes: + reserved, error = reserve_quota(organization, feature_code, amount) + if not reserved: + for reserved_feature in reserved_features: + release_quota(organization, reserved_feature, amount) + return False, error + + if amount > 0: + reserved_features.append(feature_code) + + return True, None + + +def release_quota(organization, feature_code, amount=1): + """Release ``amount`` back to the org's quota (e.g. when create failed).""" + slug = organization.slug_name + try: + subscription_id, feature_id, _, is_allowed = _get_quota_meta( + organization, feature_code + ) + if subscription_id is None or feature_id is None: + return + if not is_allowed: + return + + with transaction.atomic(): + period = timezone.now().date().replace(day=1) + usage = ( + FeatureUsage.objects.select_for_update() + .filter( + subscription_id=subscription_id, + feature_id=feature_id, + billing_period=period, + ) + .first() + ) + if usage is None: + return + + new_value = max(usage.used_value - amount, 0) + usage.used_value = new_value + usage.save(update_fields=["used_value"]) + except Exception as e: # noqa: BLE001 + logger.error("release_quota failed for %s/%s: %s", slug, feature_code, e) + + +def release_quotas(organization, feature_codes, amount=1): + for feature_code in feature_codes: + release_quota(organization, feature_code, amount) + + +def get_quota(organization, feature_code): + """Get quota for a feature.""" + slug = organization.slug_name + try: + subscription_id, feature_id, _, is_allowed = _get_quota_meta( + organization, feature_code + ) + if subscription_id is None or feature_id is None or not is_allowed: + return 0 + + period = timezone.now().date().replace(day=1) + usage = FeatureUsage.objects.filter( + subscription_id=subscription_id, + feature_id=feature_id, + billing_period=period, + ).first() + if usage is None: + return 0 + return usage.used_value + except Exception as e: # noqa: BLE001 + logger.error("get_quota failed for %s/%s: %s", slug, feature_code, e) + return 0 + + +def get_quotas(organization, feature_codes): + return { + feature_code: get_quota(organization, feature_code) + for feature_code in feature_codes + } diff --git a/apps/billing/urls.py b/apps/billing/urls.py index 7d66f20..0bc8ecd 100644 --- a/apps/billing/urls.py +++ b/apps/billing/urls.py @@ -1,9 +1,25 @@ from django.urls import path -from apps.billing.views import PlanListView +from apps.billing.views import ( + PlanListView, + QuotaView, + ReleaseQuotaView, + ReserveQuotaView, +) app_name = "billing" urlpatterns = [ path("plans", PlanListView.as_view(), name="plans"), + path( + "billing/internal/quota/reserve", + ReserveQuotaView.as_view(), + name="reserve-quota", + ), + path( + "billing/internal/quota/release", + ReleaseQuotaView.as_view(), + name="release-quota", + ), + path("billing/quota", QuotaView.as_view(), name="quota"), ] diff --git a/apps/billing/views.py b/apps/billing/views.py index 90184f8..04252e4 100644 --- a/apps/billing/views.py +++ b/apps/billing/views.py @@ -1,18 +1,26 @@ from common.pagination.base_pagination import BasePagination -from django.db.models import BooleanField, Case, Min, Prefetch, Value, When -from rest_framework import generics +from django.db.models import Min, Prefetch +from rest_framework import generics, status from rest_framework.filters import OrderingFilter +from rest_framework.response import Response -from apps.billing.constants import PlanCodeType from apps.billing.models import Plan, PlanItem -from apps.billing.serializers import PlanSerializer -from apps.organization.models import Organization +from apps.billing.serializers import ( + PlanWithFeaturesSerializer, + ReserveQuotaSerializer, + ViewQuotaSerializer, +) +from apps.billing.services.subscription import ( + get_quotas, + release_quotas, + reserve_quotas, +) class PlanListView(generics.ListAPIView): """List active subscription plans with their feature definitions.""" - serializer_class = PlanSerializer + serializer_class = PlanWithFeaturesSerializer pagination_class = BasePagination filter_backends = [OrderingFilter] ordering = ["price"] @@ -29,23 +37,104 @@ class PlanListView(generics.ListAPIView): ) ) - def get_queryset(self): - queryset = super().get_queryset() - organization_slug = self.request.headers.get("X-Organization") - if not organization_slug: - return queryset - - current_plan_code = ( - Organization.objects.filter(slug_name=organization_slug) - .values_list("subscriptions__plan_item__plan__code", flat=True) - .order_by("-subscriptions__created_at") - .first() - or PlanCodeType.FREE - ) - return queryset.annotate( - is_current_plan=Case( - When(code=current_plan_code, then=Value(True)), - default=Value(False), - output_field=BooleanField(), + +class ReserveQuotaView(generics.GenericAPIView): + """Internal endpoint — reserves quota for a feature. + + Called by other services before creating a billable resource. + + Request body:: + {"organization": "", "feature": [""], "amount": 1} + + Returns 200 if reserved, 403 if quota exceeded. + """ + + swagger_schema = None + serializer_class = ReserveQuotaSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + slug_name = serializer.validated_data["organization"] + feature_codes = serializer.validated_data["feature"] + amount = serializer.validated_data["amount"] + + from apps.organization.models import Organization + + try: + organization = Organization.objects.get(slug_name=slug_name) + except Organization.DoesNotExist: + return Response( + {"detail": f"Organization '{slug_name}' not found."}, + status=status.HTTP_404_NOT_FOUND, ) - ) + + reserved, error = reserve_quotas(organization, feature_codes, amount) + if reserved: + return Response({"status": "reserved"}) + return Response({"detail": error}, status=status.HTTP_403_FORBIDDEN) + + +class ReleaseQuotaView(generics.GenericAPIView): + """Internal endpoint — releases previously reserve quota. + + Called when resource creation failed after a successful reserve. + Always returns 200. + + Request body:: + {"organization": "", "feature": [""], "amount": 1} + """ + + swagger_schema = None + serializer_class = ReserveQuotaSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + slug_name = serializer.validated_data["organization"] + feature_codes = serializer.validated_data["feature"] + amount = serializer.validated_data["amount"] + + from apps.organization.models import Organization + + try: + organization = Organization.objects.get(slug_name=slug_name) + except Organization.DoesNotExist: + return Response( + {"detail": f"Organization '{slug_name}' not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + release_quotas(organization, feature_codes, amount) + return Response({"status": "released"}) + + +class QuotaView(generics.GenericAPIView): + """Internal endpoint — views quota for a feature. + + Request body:: + {"organization": "", "feature": [""]} + """ + + serializer_class = ViewQuotaSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + slug_name = serializer.validated_data["organization"] + feature_codes = serializer.validated_data["feature"] + + from apps.organization.models import Organization + + try: + organization = Organization.objects.get(slug_name=slug_name) + except Organization.DoesNotExist: + return Response( + {"detail": f"Organization '{slug_name}' not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + quotas = get_quotas(organization, feature_codes) + if len(feature_codes) == 1: + return Response({"quota": quotas[feature_codes[0]]}) + return Response({"quotas": quotas}) From b5264dc56aa8699ac006db896b14490a9f216cb1 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 14:12:36 +0700 Subject: [PATCH 14/29] feat: implement api to get detail plan --- apps/billing/serializers.py | 2 +- apps/billing/urls.py | 2 ++ apps/billing/views.py | 20 +++++++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/billing/serializers.py b/apps/billing/serializers.py index d1f87d2..a0f6597 100644 --- a/apps/billing/serializers.py +++ b/apps/billing/serializers.py @@ -54,7 +54,7 @@ class Meta: class PlanSerializer(serializers.ModelSerializer): plan_items = PlanItemSerializer(many=True, read_only=True) - is_current_plan = serializers.BooleanField(read_only=True) + is_current_plan = serializers.BooleanField(read_only=True, default=True) class Meta: model = Plan diff --git a/apps/billing/urls.py b/apps/billing/urls.py index 0bc8ecd..984a4c4 100644 --- a/apps/billing/urls.py +++ b/apps/billing/urls.py @@ -1,6 +1,7 @@ from django.urls import path from apps.billing.views import ( + PlanDetailView, PlanListView, QuotaView, ReleaseQuotaView, @@ -11,6 +12,7 @@ urlpatterns = [ path("plans", PlanListView.as_view(), name="plans"), + path("plans/", PlanDetailView.as_view(), name="plan-detail"), path( "billing/internal/quota/reserve", ReserveQuotaView.as_view(), diff --git a/apps/billing/views.py b/apps/billing/views.py index 04252e4..deef328 100644 --- a/apps/billing/views.py +++ b/apps/billing/views.py @@ -1,6 +1,6 @@ from common.pagination.base_pagination import BasePagination from django.db.models import Min, Prefetch -from rest_framework import generics, status +from rest_framework import generics, permissions, status from rest_framework.filters import OrderingFilter from rest_framework.response import Response @@ -38,6 +38,24 @@ class PlanListView(generics.ListAPIView): ) +class PlanDetailView(generics.RetrieveAPIView): + authentication_classes = [] + permission_classes = [permissions.AllowAny] + serializer_class = PlanWithFeaturesSerializer + lookup_field = "code" + queryset = ( + Plan.objects.filter(plan_items__is_active=True) + .distinct() + .prefetch_related( + Prefetch( + "plan_items", + queryset=PlanItem.objects.filter(is_active=True).order_by("price"), + ), + "plan_features__feature", + ) + ) + + class ReserveQuotaView(generics.GenericAPIView): """Internal endpoint — reserves quota for a feature. From e4266a949479e68feedcf905f7547166cc54b6db Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 14:23:30 +0700 Subject: [PATCH 15/29] feat: downgrade subscription when its expired --- apps/billing/services/subscription.py | 116 ++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/apps/billing/services/subscription.py b/apps/billing/services/subscription.py index adcb809..8689d8d 100644 --- a/apps/billing/services/subscription.py +++ b/apps/billing/services/subscription.py @@ -1,6 +1,8 @@ import logging from datetime import timedelta +from common.celery.task_senders import send_task +from django.core.cache import cache from django.db import transaction from django.db.models import F from django.utils import timezone @@ -224,3 +226,117 @@ def get_quotas(organization, feature_codes): feature_code: get_quota(organization, feature_code) for feature_code in feature_codes } + + +def _get_free_plan_limits(): + """Cached Free plan limits dict — queried once per hour.""" + cache_key = "billing:free_plan_limits" + limits = cache.get(cache_key) + if limits is not None: + return limits + + from apps.billing.models import Plan, PlanFeature + + free_plan = Plan.objects.filter(code=PlanCodeType.FREE).first() + limits = {} + if free_plan: + for pf in ( + PlanFeature.objects.filter(plan=free_plan) + .select_related("feature") + .iterator() + ): + code = pf.feature.code + if pf.limit_value is not None: + limits[code] = pf.limit_value + (1 if code == "space.max_count" else 0) + + cache.set(cache_key, limits, 3600) + return limits + + +def _send_subscription_tasks(prefix, payload): + for service in ("device", "space", "dashboard"): + send_task(f"{service}_{prefix}", payload) + + +def downgrade_to_free(organization): + """Downgrade an organization from any paid plan to the Free plan.""" + now = timezone.now() + + # 1. End all active non-Free subscriptions + ended = ( + Subscription.objects.filter( + organization=organization, + period_end__gt=now, + ) + .exclude(plan_item__plan__code=PlanCodeType.FREE) + .update(period_end=now) + ) + + if not ended: + logger.info( + "No active paid subscription to downgrade for org %s — " + "may already be on Free plan.", + organization.slug_name, + ) + + # 2. Check if org already has an active Free subscription + already_free = Subscription.objects.filter( + organization=organization, + period_end__gt=now, + plan_item__plan__code=PlanCodeType.FREE, + ).exists() + if already_free: + logger.info( + "Org %s already has an active Free subscription, skipping creation.", + organization.slug_name, + ) + else: + free_item = ( + PlanItem.objects.select_related("plan") + .filter( + plan__code=PlanCodeType.FREE, + billing_cycle=BillingCycle.MONTHLY, + is_active=True, + ) + .first() + ) + if free_item is None: + logger.error( + "Free plan item not found for org %s — cannot create Free subscription.", + organization.slug_name, + ) + else: + duration_days = BILLING_CYCLE_DAYS.get(free_item.billing_cycle, 30) + Subscription.objects.create( + organization=organization, + plan_item=free_item, + period_start=now, + period_end=now + timedelta(days=duration_days), + ) + logger.info( + "Created Free subscription for org %s (was on paid plan).", + organization.slug_name, + ) + + # 3. Enqueue downgrade tasks to each service + limits = _get_free_plan_limits() + payload = {"org_slug": organization.slug_name, "limits": limits} + + _send_subscription_tasks("downgrade", payload) + + logger.info( + "Org %s downgraded to Free — enqueued downgrade tasks.", + organization.slug_name, + ) + + +def renew_subscription(organization): + """Enqueue upgrade tasks so services re-activate deactivated resources.""" + payload = {"org_slug": organization.slug_name} + + _send_subscription_tasks("upgrade", payload) + + logger.info( + "Org %s subscription renewed — enqueued upgrade tasks.", + organization.slug_name, + ) From a52d6c1fbeae28af58d825adda224b7cce159671 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 14:39:12 +0700 Subject: [PATCH 16/29] feat: implement contact sales api --- apps/billing/constants.py | 1 + apps/contact_sales/__init__.py | 0 apps/contact_sales/apps.py | 7 +++ apps/contact_sales/constants.py | 2 + apps/contact_sales/service.py | 77 ++++++++++++++++++++++++++++ apps/contact_sales/urls.py | 14 ++++++ apps/contact_sales/views.py | 89 +++++++++++++++++++++++++++++++++ bootstrap_service/settings.py | 4 ++ bootstrap_service/urls.py | 1 + 9 files changed, 195 insertions(+) create mode 100644 apps/contact_sales/__init__.py create mode 100644 apps/contact_sales/apps.py create mode 100644 apps/contact_sales/constants.py create mode 100644 apps/contact_sales/service.py create mode 100644 apps/contact_sales/urls.py create mode 100644 apps/contact_sales/views.py diff --git a/apps/billing/constants.py b/apps/billing/constants.py index 45d96ba..d86e1a4 100644 --- a/apps/billing/constants.py +++ b/apps/billing/constants.py @@ -4,6 +4,7 @@ class PlanCodeType: # Plan code used as the default when provisioning a new organization. FREE = "free" + PRO = "pro" class CurrencyType(models.TextChoices): diff --git a/apps/contact_sales/__init__.py b/apps/contact_sales/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/contact_sales/apps.py b/apps/contact_sales/apps.py new file mode 100644 index 0000000..241b08b --- /dev/null +++ b/apps/contact_sales/apps.py @@ -0,0 +1,7 @@ + +from django.apps import AppConfig + + +class ContactSalesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.contact_sales" diff --git a/apps/contact_sales/constants.py b/apps/contact_sales/constants.py new file mode 100644 index 0000000..ff83bda --- /dev/null +++ b/apps/contact_sales/constants.py @@ -0,0 +1,2 @@ +LEAD_CACHE_PREFIX = "contact_sales:lead" +LEAD_CACHE_TTL = 60 * 60 * 24 * 2 # 2 days for the manual sales follow-up \ No newline at end of file diff --git a/apps/contact_sales/service.py b/apps/contact_sales/service.py new file mode 100644 index 0000000..c19f5f7 --- /dev/null +++ b/apps/contact_sales/service.py @@ -0,0 +1,77 @@ +from common.utils.email_context import get_email_context, render_email_format +from common.utils.send_email import send_email +from django.conf import settings +from django.core.cache import cache +from django.utils import timezone + +from apps.billing.constants import PlanCodeType +from apps.billing.models import Subscription +from apps.contact_sales.constants import LEAD_CACHE_PREFIX, LEAD_CACHE_TTL + + +def process_contact_sales_lead(user, org): + """Build lead from user + org, send email, store in Redis.""" + subscription = ( + Subscription.objects.filter(organization=org, period_end__gt=timezone.now()) + .order_by("-created_at") + .first() + ) + + if subscription and get_contact_sales_lead(str(subscription.id)): + return + + envelope = { + "plan_name": PlanCodeType.PRO.upper(), + "subscription_id": str(subscription.id) if subscription else None, + "name": _user_display_name(user), + "email": user.email, + "company_name": user.company_name, + "org_name": org.name, + "org_slug": org.slug_name, + "user_id": str(user.id), + "submitted_at": timezone.now().isoformat(), + } + + # Email first — if SES fails, exception propagates, lead never stored + _send_contact_sales_email(envelope) + _store_contact_sales_lead(envelope) + + +def _user_display_name(user): + full = f"{user.first_name or ''} {user.last_name or ''}".strip() + return full or user.email + + +def _store_contact_sales_lead(envelope): + subscription_id = envelope.get("subscription_id") + if not subscription_id: + return + + cache.set( + f"{LEAD_CACHE_PREFIX}:{subscription_id}", + envelope, + timeout=LEAD_CACHE_TTL, + ) + + +def get_contact_sales_lead(subscription_id): + """Return the latest contact-sales lead for an org.""" + return cache.get(f"{LEAD_CACHE_PREFIX}:{subscription_id}") + + +def _send_contact_sales_email(envelope): + email_context = get_email_context( + { + "host": settings.HOST, + **envelope, + }, + custom_email={}, + ) + message = render_email_format("email_contact_sales.html", email_context) + subject = f"New Subscription Request - {envelope.get('plan_name', 'N/A')}" + send_email( + settings.DEFAULT_FROM_EMAIL, + [settings.SALES_CONTACT_EMAIL], + subject, + message, + ) \ No newline at end of file diff --git a/apps/contact_sales/urls.py b/apps/contact_sales/urls.py new file mode 100644 index 0000000..1a23452 --- /dev/null +++ b/apps/contact_sales/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from apps.contact_sales.views import ContactSalesStatusView, ContactSalesView + +app_name = "contact_sales" + +urlpatterns = [ + path("contact-sales", ContactSalesView.as_view(), name="contact-sales"), + path( + "contact-sales/status", + ContactSalesStatusView.as_view(), + name="contact-sales-status", + ), +] \ No newline at end of file diff --git a/apps/contact_sales/views.py b/apps/contact_sales/views.py new file mode 100644 index 0000000..5f019c3 --- /dev/null +++ b/apps/contact_sales/views.py @@ -0,0 +1,89 @@ +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.authentication.models import RootUser +from apps.billing.models import Subscription +from apps.contact_sales.service import ( + get_contact_sales_lead, + process_contact_sales_lead, +) +from apps.organization.models import Organization + + +class ContactSalesView(APIView): + """Forward a contact-sales request from a logged-in org user. + All lead details are derived from the session — the caller supplies nothing. + """ + + authentication_classes = [] + + def post(self, request): + user_id = request.headers.get("X-User-ID") + user = get_object_or_404( + RootUser.objects.only( + "id", "email", "first_name", "last_name", "company_name" + ), + id=user_id, + ) + + org_slug = request.headers.get("X-Organization") + if org_slug is None: + return Response( + {"error": "Organization slug is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + org = ( + Organization.objects.filter(slug_name=org_slug) + .only("name", "slug_name") + .first() + ) + if org is None: + return Response( + {"error": f"Organization {org_slug} not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + process_contact_sales_lead(user, org) + + return Response( + {"message": "Your request has been sent to our sales team."}, + status=status.HTTP_200_OK, + ) + + +class ContactSalesStatusView(APIView): + """Return the latest contact-sales lead for the caller's org.""" + + authentication_classes = [] + + def get(self, request): + org_slug = request.headers.get("X-Organization") + if not org_slug: + return Response( + {"error": "Organization slug is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + subscription = Subscription.objects.filter( + organization__slug_name=org_slug, + period_end__gt=timezone.now(), + ).first() + + if subscription is None: + return Response( + { + "error": "No active subscription found for this organization.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + lead = get_contact_sales_lead(str(subscription.id)) + + return Response( + {"result": lead is not None}, + status=status.HTTP_200_OK, + ) diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index ef6b81b..a3c36b9 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -65,6 +65,7 @@ "apps.custom_email", "apps.organization_setting", "apps.billing", + "apps.contact_sales", "apps.organization_roles", "apps.authentication", ] @@ -241,6 +242,9 @@ def silky_intercept_func(request): EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") +# Inbox that receives contact-sales requests for manual follow-up. +SALES_CONTACT_EMAIL = os.getenv("SALES_CONTACT_EMAIL", "sales@spacedf.com") + CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", diff --git a/bootstrap_service/urls.py b/bootstrap_service/urls.py index f1bdb08..2609e21 100644 --- a/bootstrap_service/urls.py +++ b/bootstrap_service/urls.py @@ -64,5 +64,6 @@ def health_check(_): path("api/", include("apps.custom_email.urls")), path("api/", include("apps.organization_setting.urls")), path("api/", include("apps.billing.urls")), + path("api/", include("apps.contact_sales.urls")), path("api/", include("apps.organization.urls")), ] From c1144e1a5c43b4b07b91ae87b5ec50ae2115df4e Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 15:45:08 +0700 Subject: [PATCH 17/29] fix: auth space quota scopes --- .../migrations/0003_featureusage_scope.py | 92 ++++++++++++ apps/billing/models.py | 18 ++- apps/billing/serializers.py | 11 ++ apps/billing/services/subscription.py | 142 +++++++++++++++--- apps/billing/views.py | 41 ++++- apps/contact_sales/apps.py | 1 - apps/contact_sales/constants.py | 2 +- apps/contact_sales/service.py | 7 +- apps/contact_sales/urls.py | 2 +- 9 files changed, 279 insertions(+), 37 deletions(-) create mode 100644 apps/billing/migrations/0003_featureusage_scope.py diff --git a/apps/billing/migrations/0003_featureusage_scope.py b/apps/billing/migrations/0003_featureusage_scope.py new file mode 100644 index 0000000..ef6a643 --- /dev/null +++ b/apps/billing/migrations/0003_featureusage_scope.py @@ -0,0 +1,92 @@ +from django.db import migrations, models + + +def _next_month(value): + if value.month == 12: + return value.replace(year=value.year + 1, month=1) + return value.replace(month=value.month + 1) + + +def backfill_scope_and_period(apps, schema_editor): + FeatureUsage = apps.get_model("billing", "FeatureUsage") + + usages = FeatureUsage.objects.select_related("subscription").all() + for usage in usages.iterator(): + usage.scope_type = "organization" + usage.scope_id = usage.subscription.organization_id + usage.period_start = usage.billing_period + usage.period_end = _next_month(usage.billing_period) + usage.save( + update_fields=[ + "scope_type", + "scope_id", + "period_start", + "period_end", + ] + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("billing", "0002_seed_default_plans_and_features"), + ] + + operations = [ + migrations.AddField( + model_name="featureusage", + name="scope_type", + field=models.CharField( + db_index=True, default="organization", max_length=32 + ), + preserve_default=False, + ), + migrations.AddField( + model_name="featureusage", + name="scope_id", + field=models.UUIDField(blank=True, db_index=True, null=True), + ), + migrations.AddField( + model_name="featureusage", + name="period_start", + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name="featureusage", + name="period_end", + field=models.DateField(blank=True, null=True), + ), + migrations.RunPython(backfill_scope_and_period, migrations.RunPython.noop), + migrations.AlterField( + model_name="featureusage", + name="scope_id", + field=models.UUIDField(db_index=True), + ), + migrations.AlterField( + model_name="featureusage", + name="period_start", + field=models.DateField(), + ), + migrations.AlterField( + model_name="featureusage", + name="period_end", + field=models.DateField(), + ), + migrations.RemoveField( + model_name="featureusage", + name="billing_period", + ), + migrations.AddConstraint( + model_name="featureusage", + constraint=models.UniqueConstraint( + fields=( + "subscription", + "feature", + "scope_type", + "scope_id", + "period_start", + "period_end", + ), + name="unique_feature_usage_scope_period", + ), + ), + ] diff --git a/apps/billing/models.py b/apps/billing/models.py index 3811399..72cf668 100644 --- a/apps/billing/models.py +++ b/apps/billing/models.py @@ -112,8 +112,24 @@ class FeatureUsage(BaseModel): Feature, on_delete=models.CASCADE, related_name="feature_usages" ) usage_type = models.CharField(max_length=16, choices=UsageType.choices) + scope_type = models.CharField(max_length=32, db_index=True) + scope_id = models.UUIDField(db_index=True) used_value = models.BigIntegerField(default=0, validators=[MinValueValidator(0)]) - billing_period = models.DateField() + period_start = models.DateField() + period_end = models.DateField() class Meta: db_table = "feature_usages" + constraints = [ + models.UniqueConstraint( + fields=[ + "subscription", + "feature", + "scope_type", + "scope_id", + "period_start", + "period_end", + ], + name="unique_feature_usage_scope_period", + ), + ] diff --git a/apps/billing/serializers.py b/apps/billing/serializers.py index a0f6597..8eec34f 100644 --- a/apps/billing/serializers.py +++ b/apps/billing/serializers.py @@ -1,3 +1,4 @@ +from common.apps.billing.constants import FeatureUsageScope from rest_framework import serializers from apps.billing.models import Feature, Plan, PlanFeature, PlanItem @@ -99,6 +100,11 @@ class ReserveQuotaSerializer(serializers.Serializer): child=serializers.CharField(), allow_empty=False, ) + scope_type = serializers.CharField( + required=False, + default=FeatureUsageScope.ORGANIZATION, + ) + scope_id = serializers.UUIDField(required=False, allow_null=True) amount = serializers.IntegerField( default=1, min_value=0, @@ -112,3 +118,8 @@ class ViewQuotaSerializer(serializers.Serializer): child=serializers.CharField(), allow_empty=False, ) + scope_type = serializers.CharField( + required=False, + default=FeatureUsageScope.ORGANIZATION, + ) + scope_id = serializers.UUIDField(required=False, allow_null=True) diff --git a/apps/billing/services/subscription.py b/apps/billing/services/subscription.py index 8689d8d..640b33d 100644 --- a/apps/billing/services/subscription.py +++ b/apps/billing/services/subscription.py @@ -1,6 +1,7 @@ import logging from datetime import timedelta +from common.apps.billing.constants import FeatureUsageScope from common.celery.task_senders import send_task from django.core.cache import cache from django.db import transaction @@ -93,11 +94,56 @@ def _get_quota_meta(organization, feature_code): return subscription.id, feature.id, plan_feature.limit_value, True -def reserve_quota(organization, feature_code, amount=1): +def _current_period(): + period_start = timezone.now().date().replace(day=1) + if period_start.month == 12: + period_end = period_start.replace( + year=period_start.year + 1, + month=1, + ) + else: + period_end = period_start.replace(month=period_start.month + 1) + return period_start, period_end + + +def _resolve_scope(organization, scope_type=None, scope_id=None): + scope_type = scope_type or FeatureUsageScope.ORGANIZATION + if scope_type == FeatureUsageScope.ORGANIZATION: + return scope_type, organization.id + if scope_id is None: + raise ValueError(f"scope_id is required for scope_type '{scope_type}'.") + return scope_type, scope_id + + +def _usage_lookup( + subscription_id, + feature_id, + period_start, + period_end, + scope_type, + scope_id, +): + return { + "subscription_id": subscription_id, + "feature_id": feature_id, + "scope_type": scope_type, + "scope_id": scope_id, + "period_start": period_start, + "period_end": period_end, + } + + +def reserve_quota( + organization, + feature_code, + amount=1, + scope_type=None, + scope_id=None, +): """Atomically reserve ``amount`` of a feature for the org's current period. Returns ``(reserved: bool, error: str | None)``. Limited features increment - ``FeatureUsage.used_value`` atomically. Unlimited features are allowed - without tracking usage. + ``FeatureUsage.used_value`` atomically for the resolved scope. + Unlimited features are allowed without tracking usage. If the org's current plan does not include the feature, returns ``(False, error)``. """ @@ -113,11 +159,17 @@ def reserve_quota(organization, feature_code, amount=1): return True, None with transaction.atomic(): - period = timezone.now().date().replace(day=1) + period_start, period_end = _current_period() + scope_type, scope_id = _resolve_scope(organization, scope_type, scope_id) usage, _ = FeatureUsage.objects.select_for_update().get_or_create( - subscription_id=subscription_id, - feature_id=feature_id, - billing_period=period, + **_usage_lookup( + subscription_id, + feature_id, + period_start, + period_end, + scope_type, + scope_id, + ), defaults={ "usage_type": "resource", "used_value": 0, @@ -143,14 +195,32 @@ def reserve_quota(organization, feature_code, amount=1): return False, "Unable to reserve quota." -def reserve_quotas(organization, feature_codes, amount=1): +def reserve_quotas( + organization, + feature_codes, + amount=1, + scope_type=None, + scope_id=None, +): reserved_features = [] for feature_code in feature_codes: - reserved, error = reserve_quota(organization, feature_code, amount) + reserved, error = reserve_quota( + organization, + feature_code, + amount, + scope_type, + scope_id, + ) if not reserved: for reserved_feature in reserved_features: - release_quota(organization, reserved_feature, amount) + release_quota( + organization, + reserved_feature, + amount, + scope_type, + scope_id, + ) return False, error if amount > 0: @@ -159,7 +229,13 @@ def reserve_quotas(organization, feature_codes, amount=1): return True, None -def release_quota(organization, feature_code, amount=1): +def release_quota( + organization, + feature_code, + amount=1, + scope_type=None, + scope_id=None, +): """Release ``amount`` back to the org's quota (e.g. when create failed).""" slug = organization.slug_name try: @@ -172,13 +248,19 @@ def release_quota(organization, feature_code, amount=1): return with transaction.atomic(): - period = timezone.now().date().replace(day=1) + period_start, period_end = _current_period() + scope_type, scope_id = _resolve_scope(organization, scope_type, scope_id) usage = ( FeatureUsage.objects.select_for_update() .filter( - subscription_id=subscription_id, - feature_id=feature_id, - billing_period=period, + **_usage_lookup( + subscription_id, + feature_id, + period_start, + period_end, + scope_type, + scope_id, + ) ) .first() ) @@ -192,12 +274,18 @@ def release_quota(organization, feature_code, amount=1): logger.error("release_quota failed for %s/%s: %s", slug, feature_code, e) -def release_quotas(organization, feature_codes, amount=1): +def release_quotas( + organization, + feature_codes, + amount=1, + scope_type=None, + scope_id=None, +): for feature_code in feature_codes: - release_quota(organization, feature_code, amount) + release_quota(organization, feature_code, amount, scope_type, scope_id) -def get_quota(organization, feature_code): +def get_quota(organization, feature_code, scope_type=None, scope_id=None): """Get quota for a feature.""" slug = organization.slug_name try: @@ -207,11 +295,17 @@ def get_quota(organization, feature_code): if subscription_id is None or feature_id is None or not is_allowed: return 0 - period = timezone.now().date().replace(day=1) + period_start, period_end = _current_period() + scope_type, scope_id = _resolve_scope(organization, scope_type, scope_id) usage = FeatureUsage.objects.filter( - subscription_id=subscription_id, - feature_id=feature_id, - billing_period=period, + **_usage_lookup( + subscription_id, + feature_id, + period_start, + period_end, + scope_type, + scope_id, + ) ).first() if usage is None: return 0 @@ -221,9 +315,9 @@ def get_quota(organization, feature_code): return 0 -def get_quotas(organization, feature_codes): +def get_quotas(organization, feature_codes, scope_type=None, scope_id=None): return { - feature_code: get_quota(organization, feature_code) + feature_code: get_quota(organization, feature_code, scope_type, scope_id) for feature_code in feature_codes } diff --git a/apps/billing/views.py b/apps/billing/views.py index deef328..dc62bd8 100644 --- a/apps/billing/views.py +++ b/apps/billing/views.py @@ -62,7 +62,13 @@ class ReserveQuotaView(generics.GenericAPIView): Called by other services before creating a billable resource. Request body:: - {"organization": "", "feature": [""], "amount": 1} + { + "organization": "", + "feature": [""], + "amount": 1, + "scope_type": "user", + "scope_id": "" + } Returns 200 if reserved, 403 if quota exceeded. """ @@ -76,6 +82,8 @@ def post(self, request): slug_name = serializer.validated_data["organization"] feature_codes = serializer.validated_data["feature"] amount = serializer.validated_data["amount"] + scope_type = serializer.validated_data.get("scope_type") + scope_id = serializer.validated_data.get("scope_id") from apps.organization.models import Organization @@ -87,7 +95,13 @@ def post(self, request): status=status.HTTP_404_NOT_FOUND, ) - reserved, error = reserve_quotas(organization, feature_codes, amount) + reserved, error = reserve_quotas( + organization, + feature_codes, + amount, + scope_type, + scope_id, + ) if reserved: return Response({"status": "reserved"}) return Response({"detail": error}, status=status.HTTP_403_FORBIDDEN) @@ -100,7 +114,13 @@ class ReleaseQuotaView(generics.GenericAPIView): Always returns 200. Request body:: - {"organization": "", "feature": [""], "amount": 1} + { + "organization": "", + "feature": [""], + "amount": 1, + "scope_type": "user", + "scope_id": "" + } """ swagger_schema = None @@ -112,6 +132,8 @@ def post(self, request): slug_name = serializer.validated_data["organization"] feature_codes = serializer.validated_data["feature"] amount = serializer.validated_data["amount"] + scope_type = serializer.validated_data.get("scope_type") + scope_id = serializer.validated_data.get("scope_id") from apps.organization.models import Organization @@ -123,7 +145,7 @@ def post(self, request): status=status.HTTP_404_NOT_FOUND, ) - release_quotas(organization, feature_codes, amount) + release_quotas(organization, feature_codes, amount, scope_type, scope_id) return Response({"status": "released"}) @@ -131,7 +153,12 @@ class QuotaView(generics.GenericAPIView): """Internal endpoint — views quota for a feature. Request body:: - {"organization": "", "feature": [""]} + { + "organization": "", + "feature": [""], + "scope_type": "user", + "scope_id": "" + } """ serializer_class = ViewQuotaSerializer @@ -141,6 +168,8 @@ def post(self, request): serializer.is_valid(raise_exception=True) slug_name = serializer.validated_data["organization"] feature_codes = serializer.validated_data["feature"] + scope_type = serializer.validated_data.get("scope_type") + scope_id = serializer.validated_data.get("scope_id") from apps.organization.models import Organization @@ -152,7 +181,7 @@ def post(self, request): status=status.HTTP_404_NOT_FOUND, ) - quotas = get_quotas(organization, feature_codes) + quotas = get_quotas(organization, feature_codes, scope_type, scope_id) if len(feature_codes) == 1: return Response({"quota": quotas[feature_codes[0]]}) return Response({"quotas": quotas}) diff --git a/apps/contact_sales/apps.py b/apps/contact_sales/apps.py index 241b08b..3f2bfa6 100644 --- a/apps/contact_sales/apps.py +++ b/apps/contact_sales/apps.py @@ -1,4 +1,3 @@ - from django.apps import AppConfig diff --git a/apps/contact_sales/constants.py b/apps/contact_sales/constants.py index ff83bda..5612348 100644 --- a/apps/contact_sales/constants.py +++ b/apps/contact_sales/constants.py @@ -1,2 +1,2 @@ LEAD_CACHE_PREFIX = "contact_sales:lead" -LEAD_CACHE_TTL = 60 * 60 * 24 * 2 # 2 days for the manual sales follow-up \ No newline at end of file +LEAD_CACHE_TTL = 60 * 60 * 24 * 2 # 2 days for the manual sales follow-up diff --git a/apps/contact_sales/service.py b/apps/contact_sales/service.py index c19f5f7..bfc0976 100644 --- a/apps/contact_sales/service.py +++ b/apps/contact_sales/service.py @@ -21,6 +21,7 @@ def process_contact_sales_lead(user, org): return envelope = { + "header_image_url": f"{settings.HOST}/static/images/auth/subscription_request.png", "plan_name": PlanCodeType.PRO.upper(), "subscription_id": str(subscription.id) if subscription else None, "name": _user_display_name(user), @@ -48,7 +49,7 @@ def _store_contact_sales_lead(envelope): return cache.set( - f"{LEAD_CACHE_PREFIX}:{subscription_id}", + "{}:{}".format(LEAD_CACHE_PREFIX, subscription_id), envelope, timeout=LEAD_CACHE_TTL, ) @@ -56,7 +57,7 @@ def _store_contact_sales_lead(envelope): def get_contact_sales_lead(subscription_id): """Return the latest contact-sales lead for an org.""" - return cache.get(f"{LEAD_CACHE_PREFIX}:{subscription_id}") + return cache.get("{}:{}".format(LEAD_CACHE_PREFIX, subscription_id)) def _send_contact_sales_email(envelope): @@ -74,4 +75,4 @@ def _send_contact_sales_email(envelope): [settings.SALES_CONTACT_EMAIL], subject, message, - ) \ No newline at end of file + ) diff --git a/apps/contact_sales/urls.py b/apps/contact_sales/urls.py index 1a23452..dd17729 100644 --- a/apps/contact_sales/urls.py +++ b/apps/contact_sales/urls.py @@ -11,4 +11,4 @@ ContactSalesStatusView.as_view(), name="contact-sales-status", ), -] \ No newline at end of file +] From 39c14c3388ffabeb7d788c91854f5a4c2b26e3f2 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 16:02:44 +0700 Subject: [PATCH 18/29] chore: quota org header period --- apps/billing/serializers.py | 12 +----- apps/billing/views.py | 73 ++++++++++---------------------- apps/organization/serializers.py | 14 +++--- utils/request_context.py | 21 +++++++++ 4 files changed, 54 insertions(+), 66 deletions(-) create mode 100644 utils/request_context.py diff --git a/apps/billing/serializers.py b/apps/billing/serializers.py index 8eec34f..e8d0356 100644 --- a/apps/billing/serializers.py +++ b/apps/billing/serializers.py @@ -95,11 +95,7 @@ class Meta(PlanSerializer.Meta): class ReserveQuotaSerializer(serializers.Serializer): - organization = serializers.CharField() - feature = serializers.ListField( - child=serializers.CharField(), - allow_empty=False, - ) + feature = serializers.CharField() scope_type = serializers.CharField( required=False, default=FeatureUsageScope.ORGANIZATION, @@ -113,11 +109,7 @@ class ReserveQuotaSerializer(serializers.Serializer): class ViewQuotaSerializer(serializers.Serializer): - organization = serializers.CharField() - feature = serializers.ListField( - child=serializers.CharField(), - allow_empty=False, - ) + feature = serializers.CharField() scope_type = serializers.CharField( required=False, default=FeatureUsageScope.ORGANIZATION, diff --git a/apps/billing/views.py b/apps/billing/views.py index dc62bd8..b669aa9 100644 --- a/apps/billing/views.py +++ b/apps/billing/views.py @@ -10,11 +10,8 @@ ReserveQuotaSerializer, ViewQuotaSerializer, ) -from apps.billing.services.subscription import ( - get_quotas, - release_quotas, - reserve_quotas, -) +from apps.billing.services.subscription import get_quota, release_quota, reserve_quota +from utils.request_context import resolve_organization_from_header class PlanListView(generics.ListAPIView): @@ -63,8 +60,7 @@ class ReserveQuotaView(generics.GenericAPIView): Request body:: { - "organization": "", - "feature": [""], + "feature": "", "amount": 1, "scope_type": "user", "scope_id": "" @@ -79,25 +75,18 @@ class ReserveQuotaView(generics.GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - slug_name = serializer.validated_data["organization"] - feature_codes = serializer.validated_data["feature"] + feature_code = serializer.validated_data["feature"] amount = serializer.validated_data["amount"] scope_type = serializer.validated_data.get("scope_type") scope_id = serializer.validated_data.get("scope_id") - from apps.organization.models import Organization - - try: - organization = Organization.objects.get(slug_name=slug_name) - except Organization.DoesNotExist: - return Response( - {"detail": f"Organization '{slug_name}' not found."}, - status=status.HTTP_404_NOT_FOUND, - ) + organization, error_response = resolve_organization_from_header(request) + if error_response: + return error_response - reserved, error = reserve_quotas( + reserved, error = reserve_quota( organization, - feature_codes, + feature_code, amount, scope_type, scope_id, @@ -115,8 +104,7 @@ class ReleaseQuotaView(generics.GenericAPIView): Request body:: { - "organization": "", - "feature": [""], + "feature": "", "amount": 1, "scope_type": "user", "scope_id": "" @@ -129,23 +117,16 @@ class ReleaseQuotaView(generics.GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - slug_name = serializer.validated_data["organization"] - feature_codes = serializer.validated_data["feature"] + feature_code = serializer.validated_data["feature"] amount = serializer.validated_data["amount"] scope_type = serializer.validated_data.get("scope_type") scope_id = serializer.validated_data.get("scope_id") - from apps.organization.models import Organization + organization, error_response = resolve_organization_from_header(request) + if error_response: + return error_response - try: - organization = Organization.objects.get(slug_name=slug_name) - except Organization.DoesNotExist: - return Response( - {"detail": f"Organization '{slug_name}' not found."}, - status=status.HTTP_404_NOT_FOUND, - ) - - release_quotas(organization, feature_codes, amount, scope_type, scope_id) + release_quota(organization, feature_code, amount, scope_type, scope_id) return Response({"status": "released"}) @@ -154,8 +135,7 @@ class QuotaView(generics.GenericAPIView): Request body:: { - "organization": "", - "feature": [""], + "feature": "", "scope_type": "user", "scope_id": "" } @@ -166,22 +146,13 @@ class QuotaView(generics.GenericAPIView): def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - slug_name = serializer.validated_data["organization"] - feature_codes = serializer.validated_data["feature"] + feature_code = serializer.validated_data["feature"] scope_type = serializer.validated_data.get("scope_type") scope_id = serializer.validated_data.get("scope_id") - from apps.organization.models import Organization - - try: - organization = Organization.objects.get(slug_name=slug_name) - except Organization.DoesNotExist: - return Response( - {"detail": f"Organization '{slug_name}' not found."}, - status=status.HTTP_404_NOT_FOUND, - ) + organization, error_response = resolve_organization_from_header(request) + if error_response: + return error_response - quotas = get_quotas(organization, feature_codes, scope_type, scope_id) - if len(feature_codes) == 1: - return Response({"quota": quotas[feature_codes[0]]}) - return Response({"quotas": quotas}) + quota = get_quota(organization, feature_code, scope_type, scope_id) + return Response({"quota": quota}) diff --git a/apps/organization/serializers.py b/apps/organization/serializers.py index 30cab0a..2283dfa 100644 --- a/apps/organization/serializers.py +++ b/apps/organization/serializers.py @@ -48,11 +48,15 @@ def to_representation(self, instance): .order_by("-created_at") .first() ) - data["plan"] = ( - subscription.plan_item.plan.code - if subscription and subscription.plan_item and subscription.plan_item.plan - else PlanCodeType.FREE - ) + if subscription and subscription.plan_item and subscription.plan_item.plan: + data["plan"] = subscription.plan_item.plan.code + data["period_start"] = subscription.period_start + data["period_end"] = subscription.period_end + else: + data["plan"] = PlanCodeType.FREE + data["period_start"] = None + data["period_end"] = None + if instance.logo and instance.logo not in ["", None]: data["url_logo"] = get_presigned_url( settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"), diff --git a/utils/request_context.py b/utils/request_context.py new file mode 100644 index 0000000..bb2819c --- /dev/null +++ b/utils/request_context.py @@ -0,0 +1,21 @@ +from rest_framework import status +from rest_framework.response import Response + +from apps.organization.models import Organization + + +def resolve_organization_from_header(request): + slug_name = request.headers.get("X-Organization") + if not slug_name: + return None, Response( + {"detail": "X-Organization header is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + return Organization.objects.get(slug_name=slug_name), None + except Organization.DoesNotExist: + return None, Response( + {"detail": f"Organization '{slug_name}' not found."}, + status=status.HTTP_404_NOT_FOUND, + ) \ No newline at end of file From e4888309b2e87406a88e3feee5f1e14433ad8175 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 16:05:34 +0700 Subject: [PATCH 19/29] fix: ci issue --- utils/request_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/request_context.py b/utils/request_context.py index bb2819c..6ae28db 100644 --- a/utils/request_context.py +++ b/utils/request_context.py @@ -18,4 +18,4 @@ def resolve_organization_from_header(request): return None, Response( {"detail": f"Organization '{slug_name}' not found."}, status=status.HTTP_404_NOT_FOUND, - ) \ No newline at end of file + ) From 4b18f4a860725d5f89554d8d675c7848002ddbb9 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 16:28:29 +0700 Subject: [PATCH 20/29] chore: subscription cancel to free --- .../0002_seed_default_plans_and_features.py | 14 -- apps/billing/services/subscription.py | 179 +++++++++++------- apps/contact_sales/service.py | 8 +- apps/organization/serializers.py | 7 +- apps/organization/views.py | 7 +- 5 files changed, 120 insertions(+), 95 deletions(-) diff --git a/apps/billing/migrations/0002_seed_default_plans_and_features.py b/apps/billing/migrations/0002_seed_default_plans_and_features.py index 7f3fb84..8114001 100644 --- a/apps/billing/migrations/0002_seed_default_plans_and_features.py +++ b/apps/billing/migrations/0002_seed_default_plans_and_features.py @@ -27,13 +27,6 @@ "discount": 0, "billing_cycle": "monthly", }, - { - "plan_code": FREE_PLAN_CODE, - "price": 0, - "currency": "USD", - "discount": 0, - "billing_cycle": "yearly", - }, { "plan_code": PRO_PLAN_CODE, "price": 99, @@ -41,13 +34,6 @@ "discount": 10, "billing_cycle": "monthly", }, - { - "plan_code": PRO_PLAN_CODE, - "price": 99 * 12, - "discount": 10, - "currency": "USD", - "billing_cycle": "yearly", - }, ] # Feature catalog (9 features). value_type drives the PlanFeature shape: diff --git a/apps/billing/services/subscription.py b/apps/billing/services/subscription.py index 640b33d..a46e057 100644 --- a/apps/billing/services/subscription.py +++ b/apps/billing/services/subscription.py @@ -20,8 +20,35 @@ logger = logging.getLogger(__name__) -def _chargebee_item_price_id(plan_item): - return f"{plan_item.plan.code}_{plan_item.billing_cycle}" +def get_free_plan_item(): + return ( + PlanItem.objects.select_related("plan") + .filter( + plan__code=PlanCodeType.FREE, + billing_cycle=BillingCycle.MONTHLY, + is_active=True, + ) + .first() + ) + + +def get_current_subscription(organization, for_update=False): + queryset = Subscription.objects.filter(organization=organization) + if for_update: + queryset = queryset.select_for_update() + else: + queryset = queryset.select_related("plan_item__plan") + + now = timezone.now() + subscription = ( + queryset.filter(period_end__gt=now) + .order_by("-period_end", "-updated_at", "-created_at") + .first() + ) + if subscription: + return subscription + + return queryset.order_by("-created_at").first() def create_default_subscription(organization, owner=None): @@ -34,15 +61,16 @@ def create_default_subscription(organization, owner=None): Args: organization: The newly-created Organization. """ - plan_item = ( - PlanItem.objects.select_related("plan") - .filter( - plan__code=PlanCodeType.FREE, - billing_cycle=BillingCycle.MONTHLY, - is_active=True, + existing_subscription = get_current_subscription(organization) + if existing_subscription is not None: + logger.info( + "Subscription already exists for org %s, skipping default subscription " + "creation.", + organization.slug_name, ) - .first() - ) + return existing_subscription + + plan_item = get_free_plan_item() if plan_item is None: logger.warning( "Default plan item '%s' not found, skipping subscription for org %s. " @@ -70,12 +98,7 @@ def _get_quota_meta(organization, feature_code): """Resolve quota metadata for an org+feature. Returns (subscription_id, feature_id, limit_value, is_allowed). """ - subscription = ( - Subscription.objects.select_related("plan_item__plan") - .filter(organization=organization) - .order_by("-created_at") - .first() - ) + subscription = get_current_subscription(organization) if subscription is None or subscription.plan_item_id is None: return None, None, None, False @@ -341,7 +364,7 @@ def _get_free_plan_limits(): ): code = pf.feature.code if pf.limit_value is not None: - limits[code] = pf.limit_value + (1 if code == "space.max_count" else 0) + limits[code] = pf.limit_value cache.set(cache_key, limits, 3600) return limits @@ -356,63 +379,42 @@ def downgrade_to_free(organization): """Downgrade an organization from any paid plan to the Free plan.""" now = timezone.now() - # 1. End all active non-Free subscriptions - ended = ( + free_item = get_free_plan_item() + if free_item is None: + logger.error( + "Free plan item not found for org %s — cannot downgrade subscription.", + organization.slug_name, + ) + return + + with transaction.atomic(): + subscription = get_current_subscription(organization, for_update=True) + if subscription is None: + logger.warning( + "No subscription found for org %s — cannot downgrade to Free.", + organization.slug_name, + ) + return + Subscription.objects.filter( organization=organization, period_end__gt=now, + ).exclude(id=subscription.id).update(period_end=now) + + duration_days = BILLING_CYCLE_DAYS.get(free_item.billing_cycle, 30) + subscription.plan_item = free_item + subscription.period_start = now + subscription.period_end = now + timedelta(days=duration_days) + subscription.save( + update_fields=["plan_item", "period_start", "period_end", "updated_at"] ) - .exclude(plan_item__plan__code=PlanCodeType.FREE) - .update(period_end=now) - ) - if not ended: logger.info( - "No active paid subscription to downgrade for org %s — " - "may already be on Free plan.", + "Updated existing subscription %s for org %s to Free.", + subscription.id, organization.slug_name, ) - # 2. Check if org already has an active Free subscription - already_free = Subscription.objects.filter( - organization=organization, - period_end__gt=now, - plan_item__plan__code=PlanCodeType.FREE, - ).exists() - if already_free: - logger.info( - "Org %s already has an active Free subscription, skipping creation.", - organization.slug_name, - ) - else: - free_item = ( - PlanItem.objects.select_related("plan") - .filter( - plan__code=PlanCodeType.FREE, - billing_cycle=BillingCycle.MONTHLY, - is_active=True, - ) - .first() - ) - if free_item is None: - logger.error( - "Free plan item not found for org %s — cannot create Free subscription.", - organization.slug_name, - ) - else: - duration_days = BILLING_CYCLE_DAYS.get(free_item.billing_cycle, 30) - Subscription.objects.create( - organization=organization, - plan_item=free_item, - period_start=now, - period_end=now + timedelta(days=duration_days), - ) - logger.info( - "Created Free subscription for org %s (was on paid plan).", - organization.slug_name, - ) - - # 3. Enqueue downgrade tasks to each service limits = _get_free_plan_limits() payload = {"org_slug": organization.slug_name, "limits": limits} @@ -424,6 +426,53 @@ def downgrade_to_free(organization): ) +def _update_subscription_to_free(subscription): + """Update a specific subscription row to Free.""" + organization = subscription.organization + free_item = get_free_plan_item() + if free_item is None: + logger.error( + "Free plan item not found for org %s — cannot downgrade subscription %s.", + organization.slug_name, + subscription.id, + ) + return False + + now = timezone.now() + duration_days = BILLING_CYCLE_DAYS.get(free_item.billing_cycle, 30) + with transaction.atomic(): + Subscription.objects.filter( + organization=organization, + period_end__gt=now, + ).exclude(id=subscription.id).update(period_end=now) + + subscription.plan_item = free_item + subscription.period_start = now + subscription.period_end = now + timedelta(days=duration_days) + subscription.save( + update_fields=["plan_item", "period_start", "period_end", "updated_at"] + ) + return True + + +def downgrade_subscription_to_free(subscription): + """Downgrade a specific subscription row to Free and enforce Free limits.""" + updated = _update_subscription_to_free(subscription) + if not updated: + return False + + limits = _get_free_plan_limits() + payload = {"org_slug": subscription.organization.slug_name, "limits": limits} + _send_subscription_tasks("downgrade", payload) + + logger.info( + "Subscription %s for org %s downgraded to Free — enqueued downgrade tasks.", + subscription.id, + subscription.organization.slug_name, + ) + return True + + def renew_subscription(organization): """Enqueue upgrade tasks so services re-activate deactivated resources.""" payload = {"org_slug": organization.slug_name} diff --git a/apps/contact_sales/service.py b/apps/contact_sales/service.py index bfc0976..72a41b3 100644 --- a/apps/contact_sales/service.py +++ b/apps/contact_sales/service.py @@ -5,17 +5,13 @@ from django.utils import timezone from apps.billing.constants import PlanCodeType -from apps.billing.models import Subscription +from apps.billing.services.subscription import get_current_subscription from apps.contact_sales.constants import LEAD_CACHE_PREFIX, LEAD_CACHE_TTL def process_contact_sales_lead(user, org): """Build lead from user + org, send email, store in Redis.""" - subscription = ( - Subscription.objects.filter(organization=org, period_end__gt=timezone.now()) - .order_by("-created_at") - .first() - ) + subscription = get_current_subscription(org) if subscription and get_contact_sales_lead(str(subscription.id)): return diff --git a/apps/organization/serializers.py b/apps/organization/serializers.py index 2283dfa..1a09997 100644 --- a/apps/organization/serializers.py +++ b/apps/organization/serializers.py @@ -5,6 +5,7 @@ from rest_framework import serializers from apps.billing.constants import PlanCodeType +from apps.billing.services.subscription import get_current_subscription from apps.organization.models import Organization @@ -43,11 +44,7 @@ def update(self, instance, validated_data): def to_representation(self, instance): data = super().to_representation(instance) - subscription = ( - instance.subscriptions.select_related("plan_item__plan") - .order_by("-created_at") - .first() - ) + subscription = get_current_subscription(instance) if subscription and subscription.plan_item and subscription.plan_item.plan: data["plan"] = subscription.plan_item.plan.code data["period_start"] = subscription.period_start diff --git a/apps/organization/views.py b/apps/organization/views.py index a7b844d..b1c895c 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -6,6 +6,7 @@ from rest_framework.response import Response from apps.billing.constants import PlanCodeType +from apps.billing.services.subscription import get_current_subscription from apps.organization.models import Organization from apps.organization.serializers import OrganizationSerializer from apps.organization.services import get_owner_name_query_set @@ -78,11 +79,7 @@ def get(self, request, slug_name): ) .first() ) - subscription = ( - organization.subscriptions.select_related("plan_item__plan") - .order_by("-created_at") - .first() - ) + subscription = get_current_subscription(organization) plan_code = ( subscription.plan_item.plan.code if subscription and subscription.plan_item and subscription.plan_item.plan From db9a902fbf453dbd90a813d95dcf50d9f0cbdf76 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 16:47:39 +0700 Subject: [PATCH 21/29] feat: add downgrade date --- .../0002_seed_default_plans_and_features.py | 6 ++++++ apps/billing/services/subscription.py | 14 +++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/billing/migrations/0002_seed_default_plans_and_features.py b/apps/billing/migrations/0002_seed_default_plans_and_features.py index 8114001..34bed98 100644 --- a/apps/billing/migrations/0002_seed_default_plans_and_features.py +++ b/apps/billing/migrations/0002_seed_default_plans_and_features.py @@ -61,6 +61,11 @@ "name": "White-label branding", "value_type": "boolean", }, + { + "code": "automation.max_count", + "name": "Automation", + "value_type": "limit", + }, { "code": "data_retention.days", "name": "Data retention (days)", @@ -113,6 +118,7 @@ "support.email_community": {"enabled": True, "limit_value": None}, "support.priority": {"enabled": True, "limit_value": None}, "support.fully_maintenance": {"enabled": True, "limit_value": None}, + "automation.max_count": {"enabled": True, "limit_value": 5}, } PLAN_FEATURE_VALUES = { diff --git a/apps/billing/services/subscription.py b/apps/billing/services/subscription.py index a46e057..b93cbb9 100644 --- a/apps/billing/services/subscription.py +++ b/apps/billing/services/subscription.py @@ -371,7 +371,7 @@ def _get_free_plan_limits(): def _send_subscription_tasks(prefix, payload): - for service in ("device", "space", "dashboard"): + for service in ("device", "space", "dashboard", "console", "automation"): send_task(f"{service}_{prefix}", payload) @@ -416,7 +416,11 @@ def downgrade_to_free(organization): ) limits = _get_free_plan_limits() - payload = {"org_slug": organization.slug_name, "limits": limits} + payload = { + "org_slug": organization.slug_name, + "limits": limits, + "downgraded_at": now.isoformat(), + } _send_subscription_tasks("downgrade", payload) @@ -462,7 +466,11 @@ def downgrade_subscription_to_free(subscription): return False limits = _get_free_plan_limits() - payload = {"org_slug": subscription.organization.slug_name, "limits": limits} + payload = { + "org_slug": subscription.organization.slug_name, + "limits": limits, + "downgraded_at": timezone.now().isoformat(), + } _send_subscription_tasks("downgrade", payload) logger.info( From 21cb76c7c136f23066268a53201473cd0c09da33 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 17:00:35 +0700 Subject: [PATCH 22/29] feat: implement subscription email templates --- apps/billing/services/subscription.py | 165 +++++++++++++++++++++++++- apps/contact_sales/service.py | 22 ++++ 2 files changed, 186 insertions(+), 1 deletion(-) diff --git a/apps/billing/services/subscription.py b/apps/billing/services/subscription.py index b93cbb9..e78f20d 100644 --- a/apps/billing/services/subscription.py +++ b/apps/billing/services/subscription.py @@ -1,8 +1,12 @@ import logging from datetime import timedelta +from urllib.parse import urljoin from common.apps.billing.constants import FeatureUsageScope from common.celery.task_senders import send_task +from common.utils.email_context import get_email_context, render_email_format +from common.utils.send_email import send_email +from django.conf import settings from django.core.cache import cache from django.db import transaction from django.db.models import F @@ -16,6 +20,8 @@ PlanItem, Subscription, ) +from apps.organization_roles.constants import OrganizationRoleType +from apps.organization_roles.models import OrganizationRoleUser logger = logging.getLogger(__name__) @@ -375,6 +381,136 @@ def _send_subscription_tasks(prefix, payload): send_task(f"{service}_{prefix}", payload) +def _user_display_name(user): + full = f"{user.first_name or ''} {user.last_name or ''}".strip() + return full or user.email + + +def _get_organization_owner(organization): + return ( + OrganizationRoleUser.objects.select_related("root_user") + .filter( + organization_role__organization=organization, + organization_role__name__iexact=OrganizationRoleType.OWNER_ROLE, + ) + .order_by("id") + .first() + ) + + +def _get_manage_subscription_url(organization): + frontend_url = getattr(settings, "HOST_FRONTEND_ADMIN", "") + return urljoin( + frontend_url.rstrip("/") + "/", + f"organizations/{organization.slug_name}/plans", + ) + + +def _send_subscription_expired_email(subscription, plan_name, expiry_date): + owner_role_user = _get_organization_owner(subscription.organization) + if owner_role_user is None: + logger.warning( + "No owner found for org %s; skipping subscription expired email.", + subscription.organization.slug_name, + ) + return + + owner = owner_role_user.root_user + email_context = get_email_context( + { + "host": settings.HOST, + "header_image_url": ( + f"{settings.HOST}/static/images/auth/subscription_expried.png" + ), + "user_name": _user_display_name(owner), + "plan_name": plan_name, + "organization_name": subscription.organization.name, + "expiry_date": timezone.localtime(expiry_date).strftime("%B %d, %Y"), + "manage_subscription_url": _get_manage_subscription_url( + subscription.organization + ), + }, + custom_email={}, + ) + message = render_email_format("email_subscription_expired.html", email_context) + send_email( + settings.DEFAULT_FROM_EMAIL, + [owner.email], + "Your SpaceDF subscription has expired", + message, + ) + + +def _send_subscription_renewal_reminder_email(subscription): + if not subscription.plan_item or not subscription.plan_item.plan: + return + if subscription.plan_item.plan.code == PlanCodeType.FREE: + return + + owner_role_user = _get_organization_owner(subscription.organization) + if owner_role_user is None: + logger.warning( + "No owner found for org %s; skipping subscription renewal reminder email.", + subscription.organization.slug_name, + ) + return + + owner = owner_role_user.root_user + email_context = get_email_context( + { + "host": settings.HOST, + "header_image_url": ( + f"{settings.HOST}/static/images/auth/subscription_expire_soon.png" + ), + "days_until_expiry": 7, + "user_name": _user_display_name(owner), + "plan_name": subscription.plan_item.plan.name, + "organization_name": subscription.organization.name, + "expiry_date": timezone.localtime(subscription.period_end).strftime( + "%B %d, %Y" + ), + }, + custom_email={}, + ) + message = render_email_format( + "email_subscription_expiry_reminder.html", + email_context, + ) + send_email( + settings.DEFAULT_FROM_EMAIL, + [owner.email], + "Your SpaceDF subscription will expire soon", + message, + ) + + +def send_subscription_renewal_reminder(subscription): + cache_key = "billing:subscription_renewal_reminder:" "{}:{}".format( + subscription.id, subscription.period_end.isoformat() + ) + if cache.get(cache_key): + return False + + _send_subscription_renewal_reminder_email(subscription) + cache.set(cache_key, True, timeout=60 * 60 * 24 * 14) + return True + + +def _maybe_send_subscription_expired_email(subscription, plan_item, expiry_date): + if not plan_item or not plan_item.plan or plan_item.plan.code == PlanCodeType.FREE: + return + if expiry_date > timezone.now(): + logger.info( + "Subscription %s for org %s downgraded before expiry; skipping expired " + "email.", + subscription.id, + subscription.organization.slug_name, + ) + return + + _send_subscription_expired_email(subscription, plan_item.plan.name, expiry_date) + + def downgrade_to_free(organization): """Downgrade an organization from any paid plan to the Free plan.""" now = timezone.now() @@ -396,6 +532,9 @@ def downgrade_to_free(organization): ) return + previous_plan_item = subscription.plan_item + previous_period_end = subscription.period_end + Subscription.objects.filter( organization=organization, period_end__gt=now, @@ -423,6 +562,11 @@ def downgrade_to_free(organization): } _send_subscription_tasks("downgrade", payload) + _maybe_send_subscription_expired_email( + subscription, + previous_plan_item, + previous_period_end, + ) logger.info( "Org %s downgraded to Free — enqueued downgrade tasks.", @@ -461,6 +605,8 @@ def _update_subscription_to_free(subscription): def downgrade_subscription_to_free(subscription): """Downgrade a specific subscription row to Free and enforce Free limits.""" + previous_plan_item = subscription.plan_item + previous_period_end = subscription.period_end updated = _update_subscription_to_free(subscription) if not updated: return False @@ -472,6 +618,11 @@ def downgrade_subscription_to_free(subscription): "downgraded_at": timezone.now().isoformat(), } _send_subscription_tasks("downgrade", payload) + _maybe_send_subscription_expired_email( + subscription, + previous_plan_item, + previous_period_end, + ) logger.info( "Subscription %s for org %s downgraded to Free — enqueued downgrade tasks.", @@ -481,11 +632,23 @@ def downgrade_subscription_to_free(subscription): return True -def renew_subscription(organization): +def renew_subscription(organization, subscription=None, send_email_notification=False): """Enqueue upgrade tasks so services re-activate deactivated resources.""" payload = {"org_slug": organization.slug_name} _send_subscription_tasks("upgrade", payload) + if ( + send_email_notification + and subscription is not None + and subscription.plan_item + and subscription.plan_item.plan + and subscription.plan_item.plan.code != PlanCodeType.FREE + ): + _send_subscription_expired_email( + subscription, + subscription.plan_item.plan.name, + subscription.period_end, + ) logger.info( "Org %s subscription renewed — enqueued upgrade tasks.", diff --git a/apps/contact_sales/service.py b/apps/contact_sales/service.py index 72a41b3..1f0b074 100644 --- a/apps/contact_sales/service.py +++ b/apps/contact_sales/service.py @@ -31,6 +31,7 @@ def process_contact_sales_lead(user, org): # Email first — if SES fails, exception propagates, lead never stored _send_contact_sales_email(envelope) + _send_subscription_request_received_email(envelope) _store_contact_sales_lead(envelope) @@ -72,3 +73,24 @@ def _send_contact_sales_email(envelope): subject, message, ) + + +def _send_subscription_request_received_email(envelope): + email_context = get_email_context( + { + "host": settings.HOST, + **envelope, + }, + custom_email={}, + ) + message = render_email_format( + "email_subscription_request_received.html", + email_context, + ) + subject = "We received your subscription request" + send_email( + settings.DEFAULT_FROM_EMAIL, + [envelope["email"]], + subject, + message, + ) From e8108ad82931c016ef95e25873b8780edcc68339 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 17:17:04 +0700 Subject: [PATCH 23/29] feat:implement the default settings for water level --- apps/organization_monitoring/__init__.py | 0 apps/organization_monitoring/apps.py | 6 ++ apps/organization_monitoring/constants.py | 5 ++ .../migrations/0001_initial.py | 55 +++++++++++++++++++ .../migrations/__init__.py | 0 apps/organization_monitoring/models.py | 31 +++++++++++ apps/organization_monitoring/serializers.py | 23 ++++++++ apps/organization_monitoring/services.py | 23 ++++++++ apps/organization_monitoring/urls.py | 21 +++++++ apps/organization_monitoring/views.py | 20 +++++++ .../management/commands/init_organization.py | 2 + bootstrap_service/settings.py | 1 + bootstrap_service/urls.py | 1 + 13 files changed, 188 insertions(+) create mode 100644 apps/organization_monitoring/__init__.py create mode 100644 apps/organization_monitoring/apps.py create mode 100644 apps/organization_monitoring/constants.py create mode 100644 apps/organization_monitoring/migrations/0001_initial.py create mode 100644 apps/organization_monitoring/migrations/__init__.py create mode 100644 apps/organization_monitoring/models.py create mode 100644 apps/organization_monitoring/serializers.py create mode 100644 apps/organization_monitoring/services.py create mode 100644 apps/organization_monitoring/urls.py create mode 100644 apps/organization_monitoring/views.py diff --git a/apps/organization_monitoring/__init__.py b/apps/organization_monitoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/organization_monitoring/apps.py b/apps/organization_monitoring/apps.py new file mode 100644 index 0000000..fd72581 --- /dev/null +++ b/apps/organization_monitoring/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MonitoringConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.organization_monitoring" diff --git a/apps/organization_monitoring/constants.py b/apps/organization_monitoring/constants.py new file mode 100644 index 0000000..6f64980 --- /dev/null +++ b/apps/organization_monitoring/constants.py @@ -0,0 +1,5 @@ +from django.db import models + + +class MonitoringType(models.TextChoices): + WATER_LEVEL = "water_level" diff --git a/apps/organization_monitoring/migrations/0001_initial.py b/apps/organization_monitoring/migrations/0001_initial.py new file mode 100644 index 0000000..09f169d --- /dev/null +++ b/apps/organization_monitoring/migrations/0001_initial.py @@ -0,0 +1,55 @@ +import uuid + +from django.db import migrations, models + +from apps.organization_monitoring.constants import MonitoringType + + +class Migration(migrations.Migration): + initial = True + dependencies = [ + ("organization", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationMonitoring", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("cell_size", models.FloatField(default=50.0)), + ( + "type", + models.CharField(default=MonitoringType.WATER_LEVEL, max_length=50), + ), + ("thresholds", models.JSONField(blank=True, default=dict)), + ("colors", models.JSONField(blank=True, default=dict)), + ("display_settings", models.JSONField(blank=True, default=dict)), + ( + "organization", + models.ForeignKey( + on_delete=models.deletion.CASCADE, + related_name="monitoring_settings", + to="organization.Organization", + ), + ), + ], + options={"db_table": "organization_monitoring"}, + ), + migrations.AddConstraint( + model_name="organizationmonitoring", + constraint=models.UniqueConstraint( + fields=["organization", "type"], name="unique_monitoring_type_per_org" + ), + ), + ] diff --git a/apps/organization_monitoring/migrations/__init__.py b/apps/organization_monitoring/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/organization_monitoring/models.py b/apps/organization_monitoring/models.py new file mode 100644 index 0000000..d2978b0 --- /dev/null +++ b/apps/organization_monitoring/models.py @@ -0,0 +1,31 @@ +from common.models.base_model import BaseModel +from django.db import models + +from apps.organization.models import Organization +from apps.organization_monitoring.constants import MonitoringType + + +class OrganizationMonitoring(BaseModel): + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="monitoring_settings", + ) + cell_size = models.FloatField(default=50.0) + type = models.CharField( + max_length=50, + default=MonitoringType.WATER_LEVEL, + choices=MonitoringType.choices, + ) + thresholds = models.JSONField(default=dict, blank=True) + colors = models.JSONField(default=dict, blank=True) + display_settings = models.JSONField(default=dict, blank=True) + + class Meta: + db_table = "organization_monitoring" + constraints = [ + models.UniqueConstraint( + fields=["organization", "type"], + name="unique_monitoring_type_per_org", + ) + ] diff --git a/apps/organization_monitoring/serializers.py b/apps/organization_monitoring/serializers.py new file mode 100644 index 0000000..d08948a --- /dev/null +++ b/apps/organization_monitoring/serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from apps.organization_monitoring.models import OrganizationMonitoring + + +class OrganizationMonitoringSerializer(serializers.ModelSerializer): + class Meta: + model = OrganizationMonitoring + fields = [ + "id", + "cell_size", + "type", + "thresholds", + "colors", + "display_settings", + "created_at", + "updated_at", + ] + extra_kwargs = { + "id": {"read_only": True}, + "created_at": {"read_only": True}, + "updated_at": {"read_only": True}, + } diff --git a/apps/organization_monitoring/services.py b/apps/organization_monitoring/services.py new file mode 100644 index 0000000..3ce5c00 --- /dev/null +++ b/apps/organization_monitoring/services.py @@ -0,0 +1,23 @@ +from apps.organization.models import Organization +from apps.organization_monitoring.constants import MonitoringType +from apps.organization_monitoring.models import OrganizationMonitoring + +DEFAULT_MONITORING_VALUES = { + "cell_size": 50.0, + "thresholds": {"safe": 0.1, "caution": 0.3, "warning": 0.6}, + "colors": { + "safe": "#08B94E", + "caution": "#EBA622", + "warning": "#FD6665", + "danger": "#E5372B", + }, + "display_settings": {"device_icons": True, "water_column": True, "coverage": True}, +} + + +def create_default_organization_monitoring(organization: Organization): + return OrganizationMonitoring.objects.get_or_create( + organization=organization, + type=MonitoringType.WATER_LEVEL, + defaults=DEFAULT_MONITORING_VALUES, + ) diff --git a/apps/organization_monitoring/urls.py b/apps/organization_monitoring/urls.py new file mode 100644 index 0000000..54f41fb --- /dev/null +++ b/apps/organization_monitoring/urls.py @@ -0,0 +1,21 @@ +from django.urls import path + +from apps.organization_monitoring.views import ( + OrganizationMonitoringDetailView, + OrganizationMonitoringListCreateView, +) + +app_name = "organization_monitoring" + +urlpatterns = [ + path( + "organizations/monitoring", + OrganizationMonitoringListCreateView.as_view(), + name="organization-monitoring-list", + ), + path( + "organizations/monitoring/", + OrganizationMonitoringDetailView.as_view(), + name="organization-monitoring-detail", + ), +] diff --git a/apps/organization_monitoring/views.py b/apps/organization_monitoring/views.py new file mode 100644 index 0000000..ce7be01 --- /dev/null +++ b/apps/organization_monitoring/views.py @@ -0,0 +1,20 @@ +from apps.organization_monitoring.models import OrganizationMonitoring +from apps.organization_monitoring.serializers import OrganizationMonitoringSerializer +from utils.views import ( + OrganizationListCreateAPIView, + OrganizationRetrieveUpdateDestroyAPIView, +) + + +class OrganizationMonitoringListCreateView(OrganizationListCreateAPIView): + model = OrganizationMonitoring + serializer_class = OrganizationMonitoringSerializer + queryset = OrganizationMonitoring.objects.select_related("organization") + organization_field = "organization" + + +class OrganizationMonitoringDetailView(OrganizationRetrieveUpdateDestroyAPIView): + model = OrganizationMonitoring + serializer_class = OrganizationMonitoringSerializer + queryset = OrganizationMonitoring.objects.select_related("organization") + organization_field = "organization" diff --git a/bootstrap_service/management/commands/init_organization.py b/bootstrap_service/management/commands/init_organization.py index f77945b..31a079f 100644 --- a/bootstrap_service/management/commands/init_organization.py +++ b/bootstrap_service/management/commands/init_organization.py @@ -28,6 +28,7 @@ from apps.custom_email.service import create_default_organization_email from apps.custom_page.service import create_default_pages from apps.organization.models import Organization +from apps.organization_monitoring.services import create_default_organization_monitoring from apps.organization_roles.constants import OrganizationRoleType from apps.organization_roles.models import OrganizationRoleUser from apps.organization_roles.services import ( @@ -125,6 +126,7 @@ def _create_organization_with_roles(self, org_id, org_name, org_slug, result): create_default_pages(organization) create_default_organization_email(organization) create_default_organization_setting(organization) + create_default_organization_monitoring(organization) create_default_subscription(organization) self.stdout.write(self.style.SUCCESS("Created default policies")) diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index a3c36b9..f279c5e 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -64,6 +64,7 @@ "apps.custom_page", "apps.custom_email", "apps.organization_setting", + "apps.organization_monitoring", "apps.billing", "apps.contact_sales", "apps.organization_roles", diff --git a/bootstrap_service/urls.py b/bootstrap_service/urls.py index 2609e21..df759e0 100644 --- a/bootstrap_service/urls.py +++ b/bootstrap_service/urls.py @@ -63,6 +63,7 @@ def health_check(_): path("api/", include("apps.custom_page.urls")), path("api/", include("apps.custom_email.urls")), path("api/", include("apps.organization_setting.urls")), + path("api/", include("apps.organization_monitoring.urls")), path("api/", include("apps.billing.urls")), path("api/", include("apps.contact_sales.urls")), path("api/", include("apps.organization.urls")), From e31e3e5c637705f7906df9e549f1e5ac1421eb00 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Aug 2026 17:28:30 +0700 Subject: [PATCH 24/29] chore: add organization monitoring user --- apps/organization_monitoring/urls.py | 20 ++++++++++--- apps/organization_monitoring/views.py | 24 +++++++++++++-- utils/views.py | 43 +++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/apps/organization_monitoring/urls.py b/apps/organization_monitoring/urls.py index 54f41fb..9a2b11c 100644 --- a/apps/organization_monitoring/urls.py +++ b/apps/organization_monitoring/urls.py @@ -1,8 +1,10 @@ from django.urls import path from apps.organization_monitoring.views import ( - OrganizationMonitoringDetailView, - OrganizationMonitoringListCreateView, + MonitoringDetailView, + MonitoringListCreateView, + UserMonitoringDetailView, + UserMonitoringListView, ) app_name = "organization_monitoring" @@ -10,12 +12,22 @@ urlpatterns = [ path( "organizations/monitoring", - OrganizationMonitoringListCreateView.as_view(), + UserMonitoringListView.as_view(), name="organization-monitoring-list", ), path( "organizations/monitoring/", - OrganizationMonitoringDetailView.as_view(), + UserMonitoringDetailView.as_view(), name="organization-monitoring-detail", ), + path( + "console/organizations/monitoring", + MonitoringListCreateView.as_view(), + name="console-organization-monitoring-list", + ), + path( + "console/organizations/monitoring/", + MonitoringDetailView.as_view(), + name="console-organization-monitoring-detail", + ), ] diff --git a/apps/organization_monitoring/views.py b/apps/organization_monitoring/views.py index ce7be01..b1328d9 100644 --- a/apps/organization_monitoring/views.py +++ b/apps/organization_monitoring/views.py @@ -1,20 +1,40 @@ +from rest_framework import generics, permissions + from apps.organization_monitoring.models import OrganizationMonitoring from apps.organization_monitoring.serializers import OrganizationMonitoringSerializer from utils.views import ( + OrganizationContextMixin, OrganizationListCreateAPIView, OrganizationRetrieveUpdateDestroyAPIView, ) -class OrganizationMonitoringListCreateView(OrganizationListCreateAPIView): +class MonitoringListCreateView(OrganizationListCreateAPIView): model = OrganizationMonitoring serializer_class = OrganizationMonitoringSerializer queryset = OrganizationMonitoring.objects.select_related("organization") organization_field = "organization" -class OrganizationMonitoringDetailView(OrganizationRetrieveUpdateDestroyAPIView): +class MonitoringDetailView(OrganizationRetrieveUpdateDestroyAPIView): model = OrganizationMonitoring serializer_class = OrganizationMonitoringSerializer queryset = OrganizationMonitoring.objects.select_related("organization") organization_field = "organization" + + +class UserMonitoringListView(OrganizationContextMixin, generics.ListAPIView): + authentication_classes = [] + permission_classes = [permissions.AllowAny] + serializer_class = OrganizationMonitoringSerializer + queryset = OrganizationMonitoring.objects.select_related("organization") + + +class UserMonitoringDetailView( + OrganizationContextMixin, + generics.RetrieveAPIView, +): + authentication_classes = [] + permission_classes = [permissions.AllowAny] + serializer_class = OrganizationMonitoringSerializer + queryset = OrganizationMonitoring.objects.select_related("organization") diff --git a/utils/views.py b/utils/views.py index 6c358fb..ff89c9d 100644 --- a/utils/views.py +++ b/utils/views.py @@ -5,6 +5,49 @@ from apps.organization.models import Organization +class OrganizationContextMixin: + organization_field = "organization" + + def get_organization_slug(self): + headers = self.request.headers + slug_name = headers.get("X-Organization") or headers.get("X-Org") + if slug_name: + return slug_name + + tenant = getattr(self.request, "tenant", None) + slug_name = getattr(tenant, "slug_name", None) + if slug_name: + return slug_name + + hostname = self.request.get_host().split(":", 1)[0] + parts = hostname.split(".") + if len(parts) < 2: + return None + + slug_name = parts[0] + if slug_name in {"www", "api"}: + return None + return slug_name + + def get_organization(self): + slug_name = self.get_organization_slug() + if not slug_name: + raise ParseError("Organization context is required") + + try: + return Organization.objects.get(slug_name=slug_name, is_active=True) + except Organization.DoesNotExist as exc: + raise ParseError(f"Organization '{slug_name}' not found") from exc + + def get_queryset(self): + queryset = super().get_queryset() + + if getattr(self, "swagger_fake_view", False): + return queryset + + return queryset.filter(**{self.organization_field: self.get_organization()}) + + class OrganizationAPIView(GenericAPIView): organization_field = None From 630d66fa5e2646ca698c00591c7d9825b48c48e1 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Tue, 18 Aug 2026 08:47:47 +0700 Subject: [PATCH 25/29] feat: implement quota mixins --- apps/billing/quotas.py | 12 ++++++ apps/billing/services/quota_service.py | 52 ++++++++++++++++++++++++++ apps/custom_email/views.py | 5 ++- apps/custom_page/views.py | 5 ++- apps/organization_setting/views.py | 5 ++- bootstrap_service/settings.py | 2 + 6 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 apps/billing/quotas.py create mode 100644 apps/billing/services/quota_service.py diff --git a/apps/billing/quotas.py b/apps/billing/quotas.py new file mode 100644 index 0000000..b61d7f1 --- /dev/null +++ b/apps/billing/quotas.py @@ -0,0 +1,12 @@ +from common.apps.billing.constants import FeatureCode, FeatureUsageScope +from common.apps.billing.mixins import BaseQuota + + +class WhitelabelQuota(BaseQuota): + reserve_actions = set() + rules = { + ("create", "update", "partial_update", "destroy"): { + "feature": FeatureCode.WHITELABEL_ENABLED, + "scope": FeatureUsageScope.ORGANIZATION, + }, + } diff --git a/apps/billing/services/quota_service.py b/apps/billing/services/quota_service.py new file mode 100644 index 0000000..cb4ad86 --- /dev/null +++ b/apps/billing/services/quota_service.py @@ -0,0 +1,52 @@ +from apps.billing.services.subscription import release_quota, reserve_quota +from apps.organization.models import Organization + + +class BillingQuotaService: + allow_missing_organization = False + + def _get_organization(self, organization_slug): + return Organization.objects.filter(slug_name=organization_slug).first() + + def reserve_quota( + self, + organization_slug, + feature, + amount=1, + scope_type=None, + scope_id=None, + ): + organization = self._get_organization(organization_slug) + if organization is None: + return False, "Organization context required." + + return reserve_quota( + organization, + feature, + amount, + scope_type=scope_type, + scope_id=scope_id, + ) + + def release_quota( + self, + organization_slug, + feature, + amount=1, + scope_type=None, + scope_id=None, + ): + organization = self._get_organization(organization_slug) + if organization is None: + return None + + return release_quota( + organization, + feature, + amount, + scope_type=scope_type, + scope_id=scope_id, + ) + + +billing_quota_service = BillingQuotaService() diff --git a/apps/custom_email/views.py b/apps/custom_email/views.py index 4541749..e5d8cb2 100644 --- a/apps/custom_email/views.py +++ b/apps/custom_email/views.py @@ -1,15 +1,17 @@ +from common.apps.billing.mixins import QuotaMixin from common.pagination.base_pagination import BasePagination from django.db.models import Prefetch from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import OrderingFilter +from apps.billing.quotas import WhitelabelQuota from apps.custom_email.models import OrganizationEmail from apps.custom_email.serializers import OrganizationEmailSerializer from apps.organization_setting.models import OrganizationTheme from utils.views import OrganizationListAPIView -class ListCustomEmailView(OrganizationListAPIView): +class ListCustomEmailView(QuotaMixin, OrganizationListAPIView): serializer_class = OrganizationEmailSerializer queryset = OrganizationEmail.objects.select_related( "organization", @@ -25,3 +27,4 @@ class ListCustomEmailView(OrganizationListAPIView): filterset_fields = ["email_type"] filter_backends = [DjangoFilterBackend, OrderingFilter] ordering = ["-created_at"] + quota_classes = [WhitelabelQuota] diff --git a/apps/custom_page/views.py b/apps/custom_page/views.py index faea208..58f99d8 100644 --- a/apps/custom_page/views.py +++ b/apps/custom_page/views.py @@ -1,16 +1,19 @@ +from common.apps.billing.mixins import QuotaMixin from common.pagination.base_pagination import BasePagination from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import OrderingFilter +from apps.billing.quotas import WhitelabelQuota from apps.custom_page.models import CustomPage from apps.custom_page.serializers import CustomPageSerializer from utils.views import OrganizationListAPIView -class ListCustomPageView(OrganizationListAPIView): +class ListCustomPageView(QuotaMixin, OrganizationListAPIView): serializer_class = CustomPageSerializer queryset = CustomPage.objects.select_related("organization").all() organization_field = "organization" pagination_class = BasePagination filter_backends = [DjangoFilterBackend, OrderingFilter] ordering = ["-created_at"] + quota_classes = [WhitelabelQuota] diff --git a/apps/organization_setting/views.py b/apps/organization_setting/views.py index 54fcf40..e8be844 100644 --- a/apps/organization_setting/views.py +++ b/apps/organization_setting/views.py @@ -1,7 +1,9 @@ +from common.apps.billing.mixins import QuotaMixin from django.shortcuts import get_object_or_404 from rest_framework import generics from rest_framework.response import Response +from apps.billing.quotas import WhitelabelQuota from apps.organization.models import Organization from apps.organization_setting.models import OrganizationSetting from apps.organization_setting.serializers import ( @@ -10,7 +12,7 @@ ) -class UpdateOrganizationSettingView(generics.UpdateAPIView): +class UpdateOrganizationSettingView(QuotaMixin, generics.UpdateAPIView): serializer_class = UpdateOrganizationSettingSerializer queryset = OrganizationSetting.objects.select_related( "organization" @@ -19,6 +21,7 @@ class UpdateOrganizationSettingView(generics.UpdateAPIView): "organization__organization_custom_emails", "organization__organization_custom_page", ) + quota_classes = [WhitelabelQuota] def get_object(self): organization = get_object_or_404( diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index f279c5e..932e865 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -243,6 +243,8 @@ def silky_intercept_func(request): EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") +BILLING_QUOTA_SERVICE = "apps.billing.services.quota_service.billing_quota_service" + # Inbox that receives contact-sales requests for manual follow-up. SALES_CONTACT_EMAIL = os.getenv("SALES_CONTACT_EMAIL", "sales@spacedf.com") From 6d0af7a5472311f76d491ff78e1eab32dc978b57 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Tue, 18 Aug 2026 09:17:30 +0700 Subject: [PATCH 26/29] feat: subscription lifecycle routing --- .../0002_seed_default_plans_and_features.py | 1 + apps/billing/services/subscription.py | 176 +++++++++++++++--- bootstrap_service/celery.py | 29 ++- 3 files changed, 175 insertions(+), 31 deletions(-) diff --git a/apps/billing/migrations/0002_seed_default_plans_and_features.py b/apps/billing/migrations/0002_seed_default_plans_and_features.py index 34bed98..1e8453e 100644 --- a/apps/billing/migrations/0002_seed_default_plans_and_features.py +++ b/apps/billing/migrations/0002_seed_default_plans_and_features.py @@ -95,6 +95,7 @@ "device.max_count": {"enabled": True, "limit_value": 10}, "space.max_count": {"enabled": True, "limit_value": 1}, "dashboard.max_count": {"enabled": True, "limit_value": 1}, + "automation.max_count": {"enabled": True, "limit_value": 0}, "dashboard.basic_widgets": {"enabled": True, "limit_value": None}, "map_view.2d": {"enabled": True, "limit_value": None}, "map_view.3d": {"enabled": True, "limit_value": None}, diff --git a/apps/billing/services/subscription.py b/apps/billing/services/subscription.py index e78f20d..adfddfd 100644 --- a/apps/billing/services/subscription.py +++ b/apps/billing/services/subscription.py @@ -1,9 +1,10 @@ import logging +from dataclasses import dataclass from datetime import timedelta from urllib.parse import urljoin -from common.apps.billing.constants import FeatureUsageScope -from common.celery.task_senders import send_task +from common.apps.billing.constants import FeatureCode, FeatureUsageScope +from common.celery.task_senders import send_subscription_task from common.utils.email_context import get_email_context, render_email_format from common.utils.send_email import send_email from django.conf import settings @@ -26,6 +27,35 @@ logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class SubscriptionTask: + """A service that participates in subscription lifecycle events. + + ``task_name`` is derived from service + lifecycle (e.g. ``device_downgrade``) + so adding a service is one entry below, not two. ``required_features`` lists + the feature codes the caller must supply limits/entitlements for before the + task is dispatched (empty = no gating, e.g. console housekeeping). + """ + + service: str + required_features: tuple = () + + def task_name(self, lifecycle: str) -> str: + return f"{self.service}_{lifecycle}" + + +# Single source of truth for which services get subscription lifecycle tasks. +# To add a service: append one SubscriptionTask here. +# NOTE: "auth" gates SPACE_MAX_COUNT because the auth-service owns the Space model. +SUBSCRIPTION_SERVICES = ( + SubscriptionTask("device", (FeatureCode.DEVICE_MAX_COUNT,)), + SubscriptionTask("auth", (FeatureCode.SPACE_MAX_COUNT,)), + SubscriptionTask("dashboard", (FeatureCode.DASHBOARD_MAX_COUNT,)), + SubscriptionTask("console", ()), + SubscriptionTask("telemetry", (FeatureCode.AUTOMATION_MAX_COUNT,)), +) + + def get_free_plan_item(): return ( PlanItem.objects.select_related("plan") @@ -351,34 +381,112 @@ def get_quotas(organization, feature_codes, scope_type=None, scope_id=None): } -def _get_free_plan_limits(): - """Cached Free plan limits dict — queried once per hour.""" - cache_key = "billing:free_plan_limits" - limits = cache.get(cache_key) - if limits is not None: - return limits +def _get_plan_entitlements(plan): + if plan is None: + return {}, set() - from apps.billing.models import Plan, PlanFeature + cache_key = ":".join(("billing", "plan_entitlements", str(plan.id))) + entitlements = cache.get(cache_key) + if entitlements is not None: + return entitlements["limits"], set(entitlements["unlimited_features"]) - free_plan = Plan.objects.filter(code=PlanCodeType.FREE).first() limits = {} - if free_plan: - for pf in ( - PlanFeature.objects.filter(plan=free_plan) - .select_related("feature") - .iterator() - ): - code = pf.feature.code - if pf.limit_value is not None: - limits[code] = pf.limit_value - - cache.set(cache_key, limits, 3600) + unlimited_features = set() + for pf in ( + PlanFeature.objects.filter(plan=plan, enabled=True) + .select_related("feature") + .iterator() + ): + code = pf.feature.code + if pf.limit_value is not None: + if pf.limit_value < 0: + raise ValueError(f"plan limit {code} must be >= 0") + limits[code] = pf.limit_value + else: + unlimited_features.add(code) + + cache.set( + cache_key, + { + "limits": limits, + "unlimited_features": tuple(sorted(unlimited_features)), + }, + 3600, + ) + return limits, unlimited_features + + +def _get_plan_limits(plan): + limits, _ = _get_plan_entitlements(plan) return limits -def _send_subscription_tasks(prefix, payload): - for service in ("device", "space", "dashboard", "console", "automation"): - send_task(f"{service}_{prefix}", payload) +def _get_free_plan_limits(): + """Cached Free plan limits dict""" + from apps.billing.models import Plan + + free_plan = Plan.objects.filter(code=PlanCodeType.FREE).first() + return _get_plan_limits(free_plan) + + +def _subscription_entitlement_payload(subscription): + if ( + subscription is None + or not subscription.plan_item + or not subscription.plan_item.plan + ): + raise ValueError("subscription with plan is required for subscription upgrade") + + limits, unlimited_features = _get_plan_entitlements(subscription.plan_item.plan) + return { + "limits": limits, + "unlimited_features": sorted(unlimited_features), + } + + +def _send_subscription_tasks(lifecycle, payload): + for task in SUBSCRIPTION_SERVICES: + send_subscription_task( + task.service, lifecycle, task.task_name(lifecycle), payload + ) + + +def _required_subscription_features(): + """All feature codes any subscription task requires (across all services).""" + return {code for task in SUBSCRIPTION_SERVICES for code in task.required_features} + + +def _validate_no_negative_limits(limits, lifecycle): + negative = [ + code for code, value in limits.items() if value is not None and value < 0 + ] + if negative: + raise ValueError( + f"{lifecycle} limits must be >= 0: " + ", ".join(sorted(negative)) + ) + + +def _validate_downgrade_limits(limits): + required = _required_subscription_features() + missing = [code for code in required if code not in limits] + if missing: + raise ValueError( + "missing required downgrade limits: " + ", ".join(sorted(missing)) + ) + _validate_no_negative_limits(limits, "downgrade") + + +def _validate_upgrade_entitlements(limits, unlimited_features): + required = _required_subscription_features() + unlimited = set(unlimited_features) + missing = [ + code for code in required if code not in limits and code not in unlimited + ] + if missing: + raise ValueError( + "missing required upgrade entitlements: " + ", ".join(sorted(missing)) + ) + _validate_no_negative_limits(limits, "upgrade") def _user_display_name(user): @@ -523,6 +631,9 @@ def downgrade_to_free(organization): ) return + limits = _get_plan_limits(free_item.plan) + _validate_downgrade_limits(limits) + with transaction.atomic(): subscription = get_current_subscription(organization, for_update=True) if subscription is None: @@ -554,10 +665,10 @@ def downgrade_to_free(organization): organization.slug_name, ) - limits = _get_free_plan_limits() payload = { "org_slug": organization.slug_name, "limits": limits, + "unlimited_features": [], "downgraded_at": now.isoformat(), } @@ -607,14 +718,16 @@ def downgrade_subscription_to_free(subscription): """Downgrade a specific subscription row to Free and enforce Free limits.""" previous_plan_item = subscription.plan_item previous_period_end = subscription.period_end + limits = _get_free_plan_limits() + _validate_downgrade_limits(limits) updated = _update_subscription_to_free(subscription) if not updated: return False - limits = _get_free_plan_limits() payload = { "org_slug": subscription.organization.slug_name, "limits": limits, + "unlimited_features": [], "downgraded_at": timezone.now().isoformat(), } _send_subscription_tasks("downgrade", payload) @@ -634,7 +747,16 @@ def downgrade_subscription_to_free(subscription): def renew_subscription(organization, subscription=None, send_email_notification=False): """Enqueue upgrade tasks so services re-activate deactivated resources.""" - payload = {"org_slug": organization.slug_name} + current_subscription = subscription or get_current_subscription(organization) + entitlements = _subscription_entitlement_payload(current_subscription) + _validate_upgrade_entitlements( + entitlements["limits"], + entitlements["unlimited_features"], + ) + payload = { + "org_slug": organization.slug_name, + **entitlements, + } _send_subscription_tasks("upgrade", payload) if ( diff --git a/bootstrap_service/celery.py b/bootstrap_service/celery.py index f6e66bb..1c34588 100644 --- a/bootstrap_service/celery.py +++ b/bootstrap_service/celery.py @@ -7,6 +7,11 @@ from celery import Celery from common.celery import constants +from common.celery.routing import ( + append_unique_task_queues, + setup_subscription_task_routing, +) +from console_service.constants import CONSOLE_DOWNGRADE_TASK, CONSOLE_UPGRADE_TASK from dotenv import load_dotenv from kombu import Exchange, Queue @@ -16,27 +21,43 @@ app = Celery("bootstrap_service") app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() +setup_subscription_task_routing( + [ + { + "task_name": CONSOLE_DOWNGRADE_TASK, + "service": "console", + "lifecycle": "downgrade", + }, + { + "task_name": CONSOLE_UPGRADE_TASK, + "service": "console", + "lifecycle": "upgrade", + }, + ] +) + TASKS_CONSOLE = [ constants.CONSOLE_SERVICE_ADD_OR_REMOVE_SPACE, constants.CONSOLE_SERVICE_DELETE_UPLOAD_FILE, ] -existing = {queue.name: queue for queue in (app.conf.task_queues or ())} routes = dict(app.conf.task_routes or {}) +queues = [] for name in TASKS_CONSOLE: - if name not in existing: - existing[name] = Queue( + queues.append( + Queue( name, exchange=Exchange(name, type="direct"), routing_key=f"spacedf.tasks.{name}", durable=True, ) + ) routes[f"spacedf.tasks.{name}"] = { "queue": name, "routing_key": f"spacedf.tasks.{name}", } -app.conf.task_queues = tuple(existing.values()) +append_unique_task_queues(app, queues) app.conf.task_routes = routes From f84834a56eeb54fc81bf80a22581efdde32dcae8 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Tue, 18 Aug 2026 10:02:28 +0700 Subject: [PATCH 27/29] fix: use organization settings for custom pages and emails --- ...unique_organization_email_type_and_more.py | 68 ++++++++ apps/custom_email/models.py | 12 +- apps/custom_email/views.py | 7 +- ...page_organization_and_more.py\342\200\216" | 61 ++++++++ apps/custom_page/models.py | 10 +- apps/custom_page/views.py | 4 +- apps/organization/views.py | 10 +- apps/organization_setting/serializers.py | 148 +++++++++--------- apps/organization_setting/views.py | 34 +--- utils/views.py | 104 ++++++++++++ 10 files changed, 332 insertions(+), 126 deletions(-) create mode 100644 apps/custom_email/migrations/0003_remove_organizationemail_unique_organization_email_type_and_more.py create mode 100644 "apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py\342\200\216" diff --git a/apps/custom_email/migrations/0003_remove_organizationemail_unique_organization_email_type_and_more.py b/apps/custom_email/migrations/0003_remove_organizationemail_unique_organization_email_type_and_more.py new file mode 100644 index 0000000..006b3f2 --- /dev/null +++ b/apps/custom_email/migrations/0003_remove_organizationemail_unique_organization_email_type_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 5.0.6 on 2026-08-06 02:35 + +import django.db.models.deletion +from django.db import migrations, models + + +def backfill_organization_setting(apps, schema_editor): + OrganizationEmail = apps.get_model("custom_email", "OrganizationEmail") + OrganizationSetting = apps.get_model("organization_setting", "OrganizationSetting") + + organization_setting_map = { + s.organization_id: s.id + for s in OrganizationSetting.objects.only("id", "organization_id") + } + + updates = [] + + for email in OrganizationEmail.objects.exclude(organization_id=None).iterator(): + if setting_id := organization_setting_map.get(email.organization_id): + email.organization_setting_id = setting_id + updates.append(email) + + if updates: + OrganizationEmail.objects.bulk_update( + updates, + ["organization_setting"], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("custom_email", "0002_backfill_organization_emails"), + ("organization_setting", "0002_backfill_organization_settings"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="organizationemail", + name="unique_organization_email_type", + ), + migrations.AddField( + model_name="organizationemail", + name="organization_setting", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="organization_setting_custom_emails", + to="organization_setting.organizationsetting", + ), + ), + migrations.RunPython( + backfill_organization_setting, + migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name="organizationemail", + name="organization", + ), + migrations.AddConstraint( + model_name="organizationemail", + constraint=models.UniqueConstraint( + fields=("organization_setting", "email_type"), + name="unique_organization_email_type", + ), + ), + ] diff --git a/apps/custom_email/models.py b/apps/custom_email/models.py index 3b4c293..a6504d3 100644 --- a/apps/custom_email/models.py +++ b/apps/custom_email/models.py @@ -2,14 +2,16 @@ from django.db import models from apps.custom_email.constants import EmailTypes -from apps.organization.models import Organization +from apps.organization_setting.models import OrganizationSetting class OrganizationEmail(BaseModel): - organization = models.ForeignKey( - Organization, + organization_setting = models.ForeignKey( + OrganizationSetting, on_delete=models.CASCADE, - related_name="organization_custom_emails", + related_name="organization_setting_custom_emails", + null=True, + blank=True, ) email_type = models.CharField(max_length=255, choices=EmailTypes.choices) sender_name = models.CharField(max_length=255, blank=True) @@ -25,7 +27,7 @@ class Meta: db_table = "custom_emails" constraints = [ models.UniqueConstraint( - fields=["organization", "email_type"], + fields=["organization_setting", "email_type"], name="unique_organization_email_type", ) ] diff --git a/apps/custom_email/views.py b/apps/custom_email/views.py index e5d8cb2..057b5a8 100644 --- a/apps/custom_email/views.py +++ b/apps/custom_email/views.py @@ -14,15 +14,14 @@ class ListCustomEmailView(QuotaMixin, OrganizationListAPIView): serializer_class = OrganizationEmailSerializer queryset = OrganizationEmail.objects.select_related( - "organization", - "organization__organization_settings", + "organization_setting" ).prefetch_related( Prefetch( - "organization__organization_settings__themes", + "organization_setting__themes", queryset=OrganizationTheme.objects.all(), ) ) - organization_field = "organization" + organization_field = "organization_setting__organization" pagination_class = BasePagination filterset_fields = ["email_type"] filter_backends = [DjangoFilterBackend, OrderingFilter] diff --git "a/apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py\342\200\216" "b/apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py\342\200\216" new file mode 100644 index 0000000..ef66dfd --- /dev/null +++ "b/apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py\342\200\216" @@ -0,0 +1,61 @@ +# Generated by Django 5.0.6 on 2026-08-06 02:35 + +import django.db.models.deletion +from django.db import migrations, models + + +def backfill_organization_setting(apps, schema_editor): + CustomPage = apps.get_model("custom_page", "CustomPage") + OrganizationSetting = apps.get_model( + "organization_setting", + "OrganizationSetting", + ) + + organization_setting_map = { + s.organization_id: s.id + for s in OrganizationSetting.objects.only("id", "organization_id") + } + + updates = [] + + for page in CustomPage.objects.exclude(organization_id=None).iterator(): + setting_id = organization_setting_map.get(page.organization_id) + if setting_id is not None: + page.organization_setting_id = setting_id + updates.append(page) + + if updates: + CustomPage.objects.bulk_update( + updates, + ["organization_setting"], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("custom_page", "0002_backfill_default_custom_pages"), + ("organization_setting", "0002_backfill_organization_settings"), + ] + + operations = [ + migrations.AddField( + model_name="custompage", + name="organization_setting", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="organization_setting_custom_page", + to="organization_setting.organizationsetting", + ), + ), + migrations.RunPython( + backfill_organization_setting, + migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name="custompage", + name="organization", + ), + ] \ No newline at end of file diff --git a/apps/custom_page/models.py b/apps/custom_page/models.py index b5717f2..9615394 100644 --- a/apps/custom_page/models.py +++ b/apps/custom_page/models.py @@ -2,14 +2,16 @@ from django.db import models from apps.custom_page.constants import PageTypes -from apps.organization.models import Organization +from apps.organization_setting.models import OrganizationSetting class CustomPage(BaseModel): - organization = models.ForeignKey( - Organization, + organization_setting = models.ForeignKey( + OrganizationSetting, on_delete=models.CASCADE, - related_name="organization_custom_page", + related_name="organization_setting_custom_page", + null=True, + blank=True, ) page_type = models.CharField(max_length=255, choices=PageTypes.choices) title = models.CharField(max_length=255) diff --git a/apps/custom_page/views.py b/apps/custom_page/views.py index 58f99d8..f0b3101 100644 --- a/apps/custom_page/views.py +++ b/apps/custom_page/views.py @@ -11,8 +11,8 @@ class ListCustomPageView(QuotaMixin, OrganizationListAPIView): serializer_class = CustomPageSerializer - queryset = CustomPage.objects.select_related("organization").all() - organization_field = "organization" + queryset = CustomPage.objects.select_related("organization_setting").all() + organization_field = "organization_setting__organization" pagination_class = BasePagination filter_backends = [DjangoFilterBackend, OrderingFilter] ordering = ["-created_at"] diff --git a/apps/organization/views.py b/apps/organization/views.py index b1c895c..59a2d15 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -11,9 +11,7 @@ from apps.organization.serializers import OrganizationSerializer from apps.organization.services import get_owner_name_query_set from apps.organization_setting.models import OrganizationSetting -from apps.organization_setting.serializers import ( - OrganizationSettingsWithCustomPagesSerializer, -) +from apps.organization_setting.serializers import OrganizationSettingWithPagesSerializer from utils.views import OrganizationRetrieveAPIView @@ -74,8 +72,8 @@ def get(self, request, slug_name): .select_related("organization") .prefetch_related( "themes", - "organization__organization_custom_emails", - "organization__organization_custom_page", + "organization_setting_custom_emails", + "organization_setting_custom_page", ) .first() ) @@ -90,7 +88,7 @@ def get(self, request, slug_name): { "result": result, "template": organization.template, - "setting": OrganizationSettingsWithCustomPagesSerializer(setting).data + "setting": OrganizationSettingWithPagesSerializer(setting).data if setting else None, "plan": plan_code, diff --git a/apps/organization_setting/serializers.py b/apps/organization_setting/serializers.py index ab4b94a..77c1995 100644 --- a/apps/organization_setting/serializers.py +++ b/apps/organization_setting/serializers.py @@ -59,107 +59,103 @@ class Meta: } -class OrganizationSettingsExpandedSerializer(OrganizationSettingSerializer): +class OrganizationSettingWithPagesSerializer(OrganizationSettingSerializer): custom_pages = CustomPageSerializer( - source="organization.organization_custom_page", - many=True, - read_only=True, - ) - custom_emails = OrganizationEmailSerializer( - source="organization.organization_custom_emails", many=True, read_only=True, + source="organization_setting_custom_page", ) class Meta(OrganizationSettingSerializer.Meta): fields = OrganizationSettingSerializer.Meta.fields + [ "custom_pages", - "custom_emails", ] -class OrganizationSettingsWithCustomPagesSerializer(OrganizationSettingSerializer): +class UpdateOrganizationSettingSerializer(OrganizationSettingSerializer): custom_pages = CustomPageSerializer( - source="organization.organization_custom_page", many=True, - read_only=True, + required=False, + source="organization_setting_custom_page", + ) + custom_emails = OrganizationEmailSerializer( + many=True, + required=False, + source="organization_setting_custom_emails", ) class Meta(OrganizationSettingSerializer.Meta): fields = OrganizationSettingSerializer.Meta.fields + [ "custom_pages", + "custom_emails", ] - -class UpdateOrganizationSettingSerializer(serializers.Serializer): - id = serializers.UUIDField(read_only=True) - site_title = serializers.CharField(required=False, allow_blank=True) - site_description = serializers.CharField(required=False, allow_blank=True) - border_radius = serializers.JSONField(required=False) - themes = OrganizationThemeSerializer(many=True, required=False) - custom_pages = CustomPageSerializer(many=True, required=False) - custom_emails = OrganizationEmailSerializer(many=True, required=False) - brand_name = serializers.CharField(required=False, allow_blank=True) - created_at = serializers.DateTimeField(read_only=True) - updated_at = serializers.DateTimeField(read_only=True) - - def _save_instance(self, instance, data): - for attr, value in data.items(): - setattr(instance, attr, value) + def _update_instance(self, instance, data): + for field, value in data.items(): + setattr(instance, field, value) instance.save() - def _get_instance(self, queryset, data, fallback_field, default=None): - data = data.copy() - object_id = data.pop("id", None) - fallback_value = data.get(fallback_field) - instance = queryset.filter(id=object_id).first() if object_id else None - if instance is None and fallback_value: - instance = queryset.filter(**{fallback_field: fallback_value}).first() - return instance or default(data) - - def _update_themes(self, instance, themes_data): - for theme_data in themes_data or []: - theme = self._get_instance( - instance.themes, - theme_data, - "theme_key", - lambda data: instance.themes.create( - theme_key=data.get("theme_key") or "light" - ), - ) - self._save_instance(theme, theme_data) - - def _update_custom_emails(self, instance, custom_emails_data): - if not custom_emails_data: + def _upsert( + self, + manager, + items, + *, + lookup_field, + create_kwargs=None, + create_if_missing=True, + ): + if not items: return - emails = instance.organization.organization_custom_emails - for custom_email_data in custom_emails_data: - custom_email = self._get_instance( - emails, - custom_email_data, - "email_type", - lambda data: emails.create( - email_type=data.get("email_type"), - ), - ) - self._save_instance(custom_email, custom_email_data) + create_kwargs = create_kwargs or {} + for data in items: + data = data.copy() + object_id = data.pop("id", None) + lookup_value = data.get(lookup_field) + object = None - def update(self, instance, validated_data): - with transaction.atomic(): - custom_pages_data = validated_data.pop("custom_pages", []) - custom_emails_data = validated_data.pop("custom_emails", []) - themes_data = validated_data.pop("themes", []) - - self._save_instance(instance, validated_data) - self._update_themes(instance, themes_data) - self._update_custom_emails(instance, custom_emails_data) - - pages = instance.organization.organization_custom_page - for page_data in custom_pages_data: - page = self._get_instance(pages, page_data, "page_type") - if page is None: + if object_id: + object = manager.filter(id=object_id).first() + + if object is None and lookup_value is not None: + object = manager.filter(**{lookup_field: lookup_value}).first() + + if object is None: + if not create_if_missing: continue - self._save_instance(page, page_data) + + create_data = dict(create_kwargs) + if lookup_field is not None and lookup_value is not None: + create_data[lookup_field] = lookup_value + + object = manager.create(**create_data) + self._update_instance(object, data) + + @transaction.atomic + def update(self, instance, validated_data): + themes = validated_data.pop("themes", []) + custom_pages = validated_data.pop("organization_setting_custom_page", []) + custom_emails = validated_data.pop("organization_setting_custom_emails", []) + + self._update_instance(instance, validated_data) + + self._upsert( + manager=instance.themes, + items=themes, + lookup_field="theme_key", + create_kwargs={"theme_key": "light"}, + ) + + self._upsert( + manager=instance.organization_setting_custom_emails, + items=custom_emails, + lookup_field="email_type", + ) + + self._upsert( + manager=instance.organization_setting_custom_page, + items=custom_pages, + lookup_field="page_type", + ) return instance diff --git a/apps/organization_setting/views.py b/apps/organization_setting/views.py index e8be844..c87dd82 100644 --- a/apps/organization_setting/views.py +++ b/apps/organization_setting/views.py @@ -1,26 +1,19 @@ from common.apps.billing.mixins import QuotaMixin from django.shortcuts import get_object_or_404 -from rest_framework import generics -from rest_framework.response import Response from apps.billing.quotas import WhitelabelQuota from apps.organization.models import Organization from apps.organization_setting.models import OrganizationSetting -from apps.organization_setting.serializers import ( - OrganizationSettingsExpandedSerializer, - UpdateOrganizationSettingSerializer, -) +from apps.organization_setting.serializers import UpdateOrganizationSettingSerializer +from utils.views import OrganizationUpdateAPIView -class UpdateOrganizationSettingView(QuotaMixin, generics.UpdateAPIView): +class UpdateOrganizationSettingView(QuotaMixin, OrganizationUpdateAPIView): serializer_class = UpdateOrganizationSettingSerializer queryset = OrganizationSetting.objects.select_related( "organization" - ).prefetch_related( - "themes", - "organization__organization_custom_emails", - "organization__organization_custom_page", - ) + ).prefetch_related("themes") + organization_field = "organization" quota_classes = [WhitelabelQuota] def get_object(self): @@ -30,20 +23,3 @@ def get_object(self): is_active=True, ) return get_object_or_404(self.get_queryset(), organization=organization) - - def update(self, request, *args, **kwargs): - partial = kwargs.pop("partial", False) - instance = self.get_object() - serializer = self.get_serializer( - instance, - data=request.data, - partial=partial, - ) - serializer.is_valid(raise_exception=True) - updated_instance = serializer.save() - fresh_instance = self.get_queryset().get(pk=updated_instance.pk) - response_serializer = OrganizationSettingsExpandedSerializer( - fresh_instance, - context=self.get_serializer_context(), - ) - return Response(response_serializer.data) diff --git a/utils/views.py b/utils/views.py index ff89c9d..91a0178 100644 --- a/utils/views.py +++ b/utils/views.py @@ -84,6 +84,18 @@ def create_with_organization(self, serializer): return serializer.save() +class OrganizationCreateAPIView(mixins.CreateModelMixin, OrganizationAPIView): + """ + Concrete view for creating a model instance of organization. + """ + + def perform_create(self, serializer): + self.create_with_organization(serializer) + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + + class OrganizationListAPIView(mixins.ListModelMixin, OrganizationAPIView): """ Concrete view for listing a queryset of organization. @@ -100,3 +112,95 @@ class OrganizationRetrieveAPIView(mixins.RetrieveModelMixin, OrganizationAPIView def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) + + +class OrganizationDestroyAPIView(mixins.DestroyModelMixin, OrganizationAPIView): + """ + Concrete view for deleting a model instance of organization. + """ + + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) + + +class OrganizationUpdateAPIView(mixins.UpdateModelMixin, OrganizationAPIView): + """ + Concrete view for updating a model instance of organization. + """ + + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) + + +class OrganizationListCreateAPIView( + mixins.ListModelMixin, mixins.CreateModelMixin, OrganizationAPIView +): + """ + Concrete view for listing a queryset or creating a model instance of organization. + """ + + def perform_create(self, serializer): + self.create_with_organization(serializer) + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + + +class OrganizationRetrieveUpdateAPIView( + mixins.RetrieveModelMixin, mixins.UpdateModelMixin, OrganizationAPIView +): + """ + Concrete view for retrieving, updating a model instance of organization. + """ + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) + + +class OrganizationRetrieveDestroyAPIView( + mixins.RetrieveModelMixin, mixins.DestroyModelMixin, OrganizationAPIView +): + """ + Concrete view for retrieving or deleting a model instance of organization. + """ + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) + + +class OrganizationRetrieveUpdateDestroyAPIView( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + OrganizationAPIView, +): + """ + Concrete view for retrieving, updating or deleting a model instance of organization. + """ + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) From 1bb3ad3cba1c803f34d89295eee166c8332be616 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Tue, 18 Aug 2026 10:48:39 +0700 Subject: [PATCH 28/29] fix: cant create new org --- apps/custom_email/serializers.py | 7 +------ apps/custom_email/service.py | 10 +++------- apps/custom_page/service.py | 4 ++-- apps/organization_setting/services.py | 1 + .../management/commands/init_organization.py | 6 +++--- 5 files changed, 10 insertions(+), 18 deletions(-) diff --git a/apps/custom_email/serializers.py b/apps/custom_email/serializers.py index c0f0e56..31ec772 100644 --- a/apps/custom_email/serializers.py +++ b/apps/custom_email/serializers.py @@ -67,10 +67,5 @@ def get_brand_logo_light(self, instance): return get_theme_logo_url(instance, "light") def get_brand_name(self, instance): - organization = getattr(instance, "organization", None) - setting = ( - getattr(organization, "organization_settings", None) - if organization - else None - ) + setting = getattr(instance, "organization_setting", None) return getattr(setting, "brand_name", "") or "" diff --git a/apps/custom_email/service.py b/apps/custom_email/service.py index b2a8dac..2a8508d 100644 --- a/apps/custom_email/service.py +++ b/apps/custom_email/service.py @@ -9,11 +9,7 @@ def get_theme_logo_url(instance, theme_key): host = settings.HOST.rstrip("/") default_logo = "logo_white.png" if theme_key == ThemeType.DARK else "logo_black.png" - organization = getattr(instance, "organization", None) - if not organization: - return f"{host}/static/images/branding/{default_logo}" - - setting = getattr(organization, "organization_settings", None) + setting = getattr(instance, "organization_setting", None) if not setting: return f"{host}/static/images/branding/{default_logo}" @@ -80,10 +76,10 @@ def get_default_organization_emails(): ] -def create_default_organization_email(organization): +def create_default_organization_email(organization_setting): return OrganizationEmail.objects.bulk_create( [ - OrganizationEmail(organization=organization, **email_data) + OrganizationEmail(organization_setting=organization_setting, **email_data) for email_data in get_default_organization_emails() ] ) diff --git a/apps/custom_page/service.py b/apps/custom_page/service.py index 74cacca..7d826e1 100644 --- a/apps/custom_page/service.py +++ b/apps/custom_page/service.py @@ -47,11 +47,11 @@ def get_default_pages(): ] -def create_default_pages(organization): +def create_default_pages(organization_setting): default_pages = get_default_pages() list_data = [ CustomPage( - organization=organization, + organization_setting=organization_setting, page_type=page.get("page_type"), title=page.get("title"), subtitle=page.get("subtitle"), diff --git a/apps/organization_setting/services.py b/apps/organization_setting/services.py index d016f4d..8733690 100644 --- a/apps/organization_setting/services.py +++ b/apps/organization_setting/services.py @@ -69,3 +69,4 @@ def create_default_organization_setting(organization: Organization): ), ] ) + return setting diff --git a/bootstrap_service/management/commands/init_organization.py b/bootstrap_service/management/commands/init_organization.py index 31a079f..3ff3b22 100644 --- a/bootstrap_service/management/commands/init_organization.py +++ b/bootstrap_service/management/commands/init_organization.py @@ -123,9 +123,9 @@ def _create_organization_with_roles(self, org_id, org_name, org_slug, result): organization_policies = create_default_policies(organization) - create_default_pages(organization) - create_default_organization_email(organization) - create_default_organization_setting(organization) + organization_setting = create_default_organization_setting(organization) + create_default_pages(organization_setting) + create_default_organization_email(organization_setting) create_default_organization_monitoring(organization) create_default_subscription(organization) self.stdout.write(self.style.SUCCESS("Created default policies")) From 8e1ea03db224b29ad1f0fae060b5d45c06912376 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Tue, 18 Aug 2026 14:26:24 +0700 Subject: [PATCH 29/29] chore: update python dependencies --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index c183beb..a4a63c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Core -Django==5.1.15 +Django==5.2.16 celery==5.4.0 cryptography==50.0.0 python-dotenv==1.2.2 @@ -8,7 +8,7 @@ gunicorn==22.0.0 gevent==24.2.1 drf-yasg==1.21.7 djangorestframework==3.15.2 -djangorestframework_simplejwt==5.3.1 +djangorestframework_simplejwt==5.5.1 django-cors-headers==4.4.0 django-filter==24.2 django-environ==0.11.2 @@ -16,5 +16,5 @@ pika==1.3.2 django-redis==5.4.0 boto3==1.37.13 psycopg2-binary==2.9.9 -django_tenants==3.6.1 +django_tenants==3.10.2 django-silk==5.3.2 \ No newline at end of file