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..679689a --- /dev/null +++ b/apps/billing/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +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 new file mode 100644 index 0000000..d86e1a4 --- /dev/null +++ b/apps/billing/constants.py @@ -0,0 +1,34 @@ +from django.db import models + + +class PlanCodeType: + # Plan code used as the default when provisioning a new organization. + FREE = "free" + PRO = "pro" + + +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" + + +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 new file mode 100644 index 0000000..669dfa4 --- /dev/null +++ b/apps/billing/migrations/0001_initial.py @@ -0,0 +1,295 @@ +# Generated by Django 5.0.6 on 2026-07-08 08:19 + +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)), + ("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( + decimal_places=2, + default=0, + max_digits=10, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ("icon", models.CharField(blank=True, default="", max_length=256)), + ( + "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)), + ( + "plan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="plan_items", + to="billing.plan", + ), + ), + ], + options={ + "db_table": "plan_items", + }, + ), + 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_item", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="subscriptions", + to="billing.planitem", + ), + ), + ], + 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, + validators=[django.core.validators.MinValueValidator(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="planitem", + constraint=models.UniqueConstraint( + fields=("plan", "billing_cycle"), name="unique_plan_billing_cycle" + ), + ), + 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", + ), + ), + ] 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..1e8453e --- /dev/null +++ b/apps/billing/migrations/0002_seed_default_plans_and_features.py @@ -0,0 +1,232 @@ +"""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" + +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, + "currency": "USD", + "discount": 0, + "billing_cycle": "monthly", + }, + { + "plan_code": PRO_PLAN_CODE, + "price": 99, + "currency": "USD", + "discount": 10, + "billing_cycle": "monthly", + }, +] + +# 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": "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", + "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": "automation.max_count", + "name": "Automation", + "value_type": "limit", + }, + { + "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}, + "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}, + "data_retention.days": {"enabled": True, "limit_value": 7}, + "support.onboarding_video": {"enabled": True, "limit_value": None}, + "support.email": {"enabled": True, "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}, + "automation.max_count": {"enabled": True, "limit_value": 5}, +} + +PLAN_FEATURE_VALUES = { + FREE_PLAN_CODE: FREE_FEATURES, + PRO_PLAN_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={ + "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 = {} + 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) + _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_item__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() + 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() + + +class Migration(migrations.Migration): + dependencies = [ + ("billing", "0001_initial"), + ] + + operations = [migrations.RunPython(forward, reverse)] 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/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..72cf668 --- /dev/null +++ b/apps/billing/models.py @@ -0,0 +1,135 @@ +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, + FeatureValueType, + UsageType, +) +from apps.organization.models import Organization + + +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)] + ) + icon = models.CharField(max_length=256, blank=True, default="") + 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 = "plan_items" + constraints = [ + models.UniqueConstraint( + fields=["plan", "billing_cycle"], name="unique_plan_billing_cycle" + ), + ] + + +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_item = models.ForeignKey( + PlanItem, + null=True, + blank=True, + on_delete=models.SET_NULL, + 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) + 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)]) + 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/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/serializers.py b/apps/billing/serializers.py new file mode 100644 index 0000000..e8d0356 --- /dev/null +++ b/apps/billing/serializers.py @@ -0,0 +1,117 @@ +from common.apps.billing.constants import FeatureUsageScope +from rest_framework import serializers + +from apps.billing.models import Feature, Plan, PlanFeature, PlanItem + + +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 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) + is_current_plan = serializers.BooleanField(read_only=True, default=True) + + class Meta: + model = Plan + fields = [ + "id", + "name", + "code", + "description", + "plan_items", + "is_current_plan", + "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) + ["plan_features"] + + +class ReserveQuotaSerializer(serializers.Serializer): + feature = serializers.CharField() + 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, + required=False, + ) + + +class ViewQuotaSerializer(serializers.Serializer): + feature = serializers.CharField() + scope_type = serializers.CharField( + required=False, + default=FeatureUsageScope.ORGANIZATION, + ) + scope_id = serializers.UUIDField(required=False, allow_null=True) 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/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/billing/services/subscription.py b/apps/billing/services/subscription.py new file mode 100644 index 0000000..adfddfd --- /dev/null +++ b/apps/billing/services/subscription.py @@ -0,0 +1,778 @@ +import logging +from dataclasses import dataclass +from datetime import timedelta +from urllib.parse import urljoin + +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 +from django.core.cache import cache +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 ( + Feature, + FeatureUsage, + PlanFeature, + PlanItem, + Subscription, +) +from apps.organization_roles.constants import OrganizationRoleType +from apps.organization_roles.models import OrganizationRoleUser + +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") + .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): + """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. + """ + 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, + ) + 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. " + "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 + + +def _get_quota_meta(organization, feature_code): + """Resolve quota metadata for an org+feature. + Returns (subscription_id, feature_id, limit_value, is_allowed). + """ + subscription = get_current_subscription(organization) + 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 _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 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)``. + """ + 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_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( + **_usage_lookup( + subscription_id, + feature_id, + period_start, + period_end, + scope_type, + scope_id, + ), + 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, + scope_type=None, + scope_id=None, +): + reserved_features = [] + + for feature_code in feature_codes: + 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, + scope_type, + scope_id, + ) + return False, error + + if amount > 0: + reserved_features.append(feature_code) + + return True, None + + +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: + 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_start, period_end = _current_period() + scope_type, scope_id = _resolve_scope(organization, scope_type, scope_id) + usage = ( + FeatureUsage.objects.select_for_update() + .filter( + **_usage_lookup( + subscription_id, + feature_id, + period_start, + period_end, + scope_type, + scope_id, + ) + ) + .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, + scope_type=None, + scope_id=None, +): + for feature_code in feature_codes: + release_quota(organization, feature_code, amount, scope_type, scope_id) + + +def get_quota(organization, feature_code, scope_type=None, scope_id=None): + """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_start, period_end = _current_period() + scope_type, scope_id = _resolve_scope(organization, scope_type, scope_id) + usage = FeatureUsage.objects.filter( + **_usage_lookup( + subscription_id, + feature_id, + period_start, + period_end, + scope_type, + scope_id, + ) + ).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, scope_type=None, scope_id=None): + return { + feature_code: get_quota(organization, feature_code, scope_type, scope_id) + for feature_code in feature_codes + } + + +def _get_plan_entitlements(plan): + if plan is None: + return {}, set() + + 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"]) + + limits = {} + 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 _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): + 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() + + 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 + + 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: + logger.warning( + "No subscription found for org %s — cannot downgrade to Free.", + organization.slug_name, + ) + return + + previous_plan_item = subscription.plan_item + previous_period_end = subscription.period_end + + 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"] + ) + + logger.info( + "Updated existing subscription %s for org %s to Free.", + subscription.id, + organization.slug_name, + ) + + payload = { + "org_slug": organization.slug_name, + "limits": limits, + "unlimited_features": [], + "downgraded_at": now.isoformat(), + } + + _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.", + organization.slug_name, + ) + + +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.""" + 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 + + payload = { + "org_slug": subscription.organization.slug_name, + "limits": limits, + "unlimited_features": [], + "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.", + subscription.id, + subscription.organization.slug_name, + ) + return True + + +def renew_subscription(organization, subscription=None, send_email_notification=False): + """Enqueue upgrade tasks so services re-activate deactivated resources.""" + 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 ( + 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.", + organization.slug_name, + ) diff --git a/apps/billing/urls.py b/apps/billing/urls.py new file mode 100644 index 0000000..984a4c4 --- /dev/null +++ b/apps/billing/urls.py @@ -0,0 +1,27 @@ +from django.urls import path + +from apps.billing.views import ( + PlanDetailView, + PlanListView, + QuotaView, + ReleaseQuotaView, + ReserveQuotaView, +) + +app_name = "billing" + +urlpatterns = [ + path("plans", PlanListView.as_view(), name="plans"), + path("plans/", PlanDetailView.as_view(), name="plan-detail"), + 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 new file mode 100644 index 0000000..b669aa9 --- /dev/null +++ b/apps/billing/views.py @@ -0,0 +1,158 @@ +from common.pagination.base_pagination import BasePagination +from django.db.models import Min, Prefetch +from rest_framework import generics, permissions, status +from rest_framework.filters import OrderingFilter +from rest_framework.response import Response + +from apps.billing.models import Plan, PlanItem +from apps.billing.serializers import ( + PlanWithFeaturesSerializer, + ReserveQuotaSerializer, + ViewQuotaSerializer, +) +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): + """List active subscription plans with their feature definitions.""" + + serializer_class = PlanWithFeaturesSerializer + pagination_class = BasePagination + filter_backends = [OrderingFilter] + ordering = ["price"] + 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", + ) + ) + + +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. + + Called by other services before creating a billable resource. + + Request body:: + { + "feature": "", + "amount": 1, + "scope_type": "user", + "scope_id": "" + } + + 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) + 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") + + organization, error_response = resolve_organization_from_header(request) + if error_response: + return error_response + + reserved, error = reserve_quota( + organization, + feature_code, + amount, + scope_type, + scope_id, + ) + 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:: + { + "feature": "", + "amount": 1, + "scope_type": "user", + "scope_id": "" + } + """ + + swagger_schema = None + serializer_class = ReserveQuotaSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + 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") + + organization, error_response = resolve_organization_from_header(request) + if error_response: + return error_response + + release_quota(organization, feature_code, amount, scope_type, scope_id) + return Response({"status": "released"}) + + +class QuotaView(generics.GenericAPIView): + """Internal endpoint — views quota for a feature. + + Request body:: + { + "feature": "", + "scope_type": "user", + "scope_id": "" + } + """ + + serializer_class = ViewQuotaSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + feature_code = serializer.validated_data["feature"] + scope_type = serializer.validated_data.get("scope_type") + scope_id = serializer.validated_data.get("scope_id") + + organization, error_response = resolve_organization_from_header(request) + if error_response: + return error_response + + quota = get_quota(organization, feature_code, scope_type, scope_id) + return Response({"quota": quota}) 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..3f2bfa6 --- /dev/null +++ b/apps/contact_sales/apps.py @@ -0,0 +1,6 @@ +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..5612348 --- /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 diff --git a/apps/contact_sales/service.py b/apps/contact_sales/service.py new file mode 100644 index 0000000..1f0b074 --- /dev/null +++ b/apps/contact_sales/service.py @@ -0,0 +1,96 @@ +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.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 = get_current_subscription(org) + + if subscription and get_contact_sales_lead(str(subscription.id)): + 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), + "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) + _send_subscription_request_received_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( + "{}:{}".format(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("{}:{}".format(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, + ) + + +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, + ) diff --git a/apps/contact_sales/urls.py b/apps/contact_sales/urls.py new file mode 100644 index 0000000..dd17729 --- /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", + ), +] 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/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/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_email/views.py b/apps/custom_email/views.py index 4541749..057b5a8 100644 --- a/apps/custom_email/views.py +++ b/apps/custom_email/views.py @@ -1,27 +1,29 @@ +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", - "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] ordering = ["-created_at"] + quota_classes = [WhitelabelQuota] 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/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/custom_page/views.py b/apps/custom_page/views.py index faea208..f0b3101 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" + queryset = CustomPage.objects.select_related("organization_setting").all() + organization_field = "organization_setting__organization" pagination_class = BasePagination filter_backends = [DjangoFilterBackend, OrderingFilter] ordering = ["-created_at"] + quota_classes = [WhitelabelQuota] diff --git a/apps/organization/serializers.py b/apps/organization/serializers.py index 3174db9..1a09997 100644 --- a/apps/organization/serializers.py +++ b/apps/organization/serializers.py @@ -4,6 +4,8 @@ from django.conf import settings 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 @@ -42,6 +44,16 @@ def update(self, instance, validated_data): def to_representation(self, instance): data = super().to_representation(instance) + 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 + 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/apps/organization/views.py b/apps/organization/views.py index b2d0c29..59a2d15 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -5,13 +5,13 @@ from rest_framework.filters import OrderingFilter, SearchFilter 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 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 @@ -72,19 +72,26 @@ 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() ) + subscription = get_current_subscription(organization) + plan_code = ( + subscription.plan_item.plan.code + if subscription and subscription.plan_item and subscription.plan_item.plan + else PlanCodeType.FREE + ) return Response( { "result": result, "template": organization.template, - "setting": OrganizationSettingsWithCustomPagesSerializer(setting).data + "setting": OrganizationSettingWithPagesSerializer(setting).data if setting else None, + "plan": plan_code, }, status=status.HTTP_200_OK, ) 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..9a2b11c --- /dev/null +++ b/apps/organization_monitoring/urls.py @@ -0,0 +1,33 @@ +from django.urls import path + +from apps.organization_monitoring.views import ( + MonitoringDetailView, + MonitoringListCreateView, + UserMonitoringDetailView, + UserMonitoringListView, +) + +app_name = "organization_monitoring" + +urlpatterns = [ + path( + "organizations/monitoring", + UserMonitoringListView.as_view(), + name="organization-monitoring-list", + ), + path( + "organizations/monitoring/", + 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 new file mode 100644 index 0000000..b1328d9 --- /dev/null +++ b/apps/organization_monitoring/views.py @@ -0,0 +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 MonitoringListCreateView(OrganizationListCreateAPIView): + model = OrganizationMonitoring + serializer_class = OrganizationMonitoringSerializer + queryset = OrganizationMonitoring.objects.select_related("organization") + organization_field = "organization" + + +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/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/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/apps/organization_setting/views.py b/apps/organization_setting/views.py index 54fcf40..c87dd82 100644 --- a/apps/organization_setting/views.py +++ b/apps/organization_setting/views.py @@ -1,24 +1,20 @@ +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(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): organization = get_object_or_404( @@ -27,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/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 diff --git a/bootstrap_service/management/commands/init_organization.py b/bootstrap_service/management/commands/init_organization.py index 244a646..3ff3b22 100644 --- a/bootstrap_service/management/commands/init_organization.py +++ b/bootstrap_service/management/commands/init_organization.py @@ -24,9 +24,11 @@ from django.utils import timezone from apps.authentication.models import RootUser +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 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 ( @@ -121,9 +123,11 @@ 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")) role_mappings = [ diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index f74dfbd..932e865 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -64,6 +64,9 @@ "apps.custom_page", "apps.custom_email", "apps.organization_setting", + "apps.organization_monitoring", + "apps.billing", + "apps.contact_sales", "apps.organization_roles", "apps.authentication", ] @@ -240,6 +243,11 @@ 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") + CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", diff --git a/bootstrap_service/urls.py b/bootstrap_service/urls.py index f764f69..df759e0 100644 --- a/bootstrap_service/urls.py +++ b/bootstrap_service/urls.py @@ -63,5 +63,8 @@ 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")), ] diff --git a/requirements.txt b/requirements.txt index ba1f2eb..a4a63c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,14 @@ # Core -Django==5.0.6 +Django==5.2.16 celery==5.4.0 -cryptography==43.0.3 -python-dotenv==1.0.1 -requests==2.31.0 +cryptography==50.0.0 +python-dotenv==1.2.2 +requests==2.33.0 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 diff --git a/utils/request_context.py b/utils/request_context.py new file mode 100644 index 0000000..6ae28db --- /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, + ) diff --git a/utils/views.py b/utils/views.py index 6c358fb..91a0178 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 @@ -41,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. @@ -57,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)