From 8610478822055cf620f16ac1a7b52a8e67175530 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 14 Sep 2026 11:44:48 +0700 Subject: [PATCH] chore: remove contact sales flow --- apps/billing/apps.py | 4 - apps/billing/constants.py | 1 - apps/billing/migrations/0001_initial.py | 2 +- .../0002_seed_default_plans_and_features.py | 80 +--- apps/billing/serializers.py | 92 ---- apps/billing/services/__init__.py | 3 +- apps/billing/services/subscription.py | 439 +----------------- apps/billing/urls.py | 10 +- apps/billing/views.py | 53 +-- apps/contact_sales/__init__.py | 0 apps/contact_sales/apps.py | 6 - apps/contact_sales/constants.py | 2 - apps/contact_sales/service.py | 96 ---- apps/contact_sales/urls.py | 14 - apps/contact_sales/views.py | 89 ---- apps/custom_email/migrations/0001_initial.py | 2 +- apps/custom_page/migrations/0001_initial.py | 2 +- ...remove_custompage_organization_and_more.py | 2 +- apps/organization_monitoring/urls.py | 8 +- .../migrations/0001_initial.py | 2 +- bootstrap_service/celery.py | 20 +- .../management/commands/init_organization.py | 3 + bootstrap_service/settings.py | 4 - bootstrap_service/urls.py | 1 - requirements.txt | 2 +- 25 files changed, 25 insertions(+), 912 deletions(-) delete mode 100644 apps/contact_sales/__init__.py delete mode 100644 apps/contact_sales/apps.py delete mode 100644 apps/contact_sales/constants.py delete mode 100644 apps/contact_sales/service.py delete mode 100644 apps/contact_sales/urls.py delete mode 100644 apps/contact_sales/views.py rename "apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py\342\200\216" => apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py (99%) diff --git a/apps/billing/apps.py b/apps/billing/apps.py index 679689a..26831f1 100644 --- a/apps/billing/apps.py +++ b/apps/billing/apps.py @@ -4,7 +4,3 @@ class BillingConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.billing" - - def ready(self): - # Importing tasks connects the task_failure signal handler defined there. - from apps.billing import tasks # noqa: F401 diff --git a/apps/billing/constants.py b/apps/billing/constants.py index d86e1a4..45d96ba 100644 --- a/apps/billing/constants.py +++ b/apps/billing/constants.py @@ -4,7 +4,6 @@ class PlanCodeType: # Plan code used as the default when provisioning a new organization. FREE = "free" - PRO = "pro" class CurrencyType(models.TextChoices): diff --git a/apps/billing/migrations/0001_initial.py b/apps/billing/migrations/0001_initial.py index 669dfa4..735483d 100644 --- a/apps/billing/migrations/0001_initial.py +++ b/apps/billing/migrations/0001_initial.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("organization", "0004_alter_organization_template"), + ("organization", "0001_initial"), ] operations = [ diff --git a/apps/billing/migrations/0002_seed_default_plans_and_features.py b/apps/billing/migrations/0002_seed_default_plans_and_features.py index 1e8453e..5625d91 100644 --- a/apps/billing/migrations/0002_seed_default_plans_and_features.py +++ b/apps/billing/migrations/0002_seed_default_plans_and_features.py @@ -1,21 +1,15 @@ -"""Seed default Free and Pro plans with their feature definitions.""" +"""Seed the default Community plan with its 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", + "name": "Community", + "description": "Default open-source workspace limits.", }, ] @@ -27,35 +21,15 @@ "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: +# Feature catalog. 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", @@ -66,28 +40,6 @@ "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; @@ -96,35 +48,11 @@ "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] diff --git a/apps/billing/serializers.py b/apps/billing/serializers.py index e8d0356..21e9cc4 100644 --- a/apps/billing/serializers.py +++ b/apps/billing/serializers.py @@ -1,98 +1,6 @@ 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() diff --git a/apps/billing/services/__init__.py b/apps/billing/services/__init__.py index fc68166..bb0094d 100644 --- a/apps/billing/services/__init__.py +++ b/apps/billing/services/__init__.py @@ -1,4 +1,3 @@ -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"] +__all__ = ["create_default_subscription"] diff --git a/apps/billing/services/subscription.py b/apps/billing/services/subscription.py index adfddfd..79d06fd 100644 --- a/apps/billing/services/subscription.py +++ b/apps/billing/services/subscription.py @@ -1,14 +1,7 @@ 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 common.apps.billing.constants import FeatureUsageScope from django.db import transaction from django.db.models import F from django.utils import timezone @@ -21,41 +14,10 @@ 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") @@ -379,400 +341,3 @@ def get_quotas(organization, feature_codes, scope_type=None, scope_id=None): 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 index 984a4c4..5ee02c1 100644 --- a/apps/billing/urls.py +++ b/apps/billing/urls.py @@ -1,18 +1,10 @@ from django.urls import path -from apps.billing.views import ( - PlanDetailView, - PlanListView, - QuotaView, - ReleaseQuotaView, - ReserveQuotaView, -) +from apps.billing.views import 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(), diff --git a/apps/billing/views.py b/apps/billing/views.py index b669aa9..15676d2 100644 --- a/apps/billing/views.py +++ b/apps/billing/views.py @@ -1,58 +1,11 @@ -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 import generics, status from rest_framework.response import Response -from apps.billing.models import Plan, PlanItem -from apps.billing.serializers import ( - PlanWithFeaturesSerializer, - ReserveQuotaSerializer, - ViewQuotaSerializer, -) +from apps.billing.serializers import 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. @@ -62,7 +15,7 @@ class ReserveQuotaView(generics.GenericAPIView): { "feature": "", "amount": 1, - "scope_type": "user", + "scope_type": "", "scope_id": "" } diff --git a/apps/contact_sales/__init__.py b/apps/contact_sales/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/contact_sales/apps.py b/apps/contact_sales/apps.py deleted file mode 100644 index 3f2bfa6..0000000 --- a/apps/contact_sales/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 5612348..0000000 --- a/apps/contact_sales/constants.py +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 1f0b074..0000000 --- a/apps/contact_sales/service.py +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index dd17729..0000000 --- a/apps/contact_sales/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 5f019c3..0000000 --- a/apps/contact_sales/views.py +++ /dev/null @@ -1,89 +0,0 @@ -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/0001_initial.py b/apps/custom_email/migrations/0001_initial.py index 6cf36d9..26945a3 100644 --- a/apps/custom_email/migrations/0001_initial.py +++ b/apps/custom_email/migrations/0001_initial.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("organization", "0004_alter_organization_template"), + ("organization", "0001_initial"), ] operations = [ diff --git a/apps/custom_page/migrations/0001_initial.py b/apps/custom_page/migrations/0001_initial.py index 9a5840b..d3e4a7c 100644 --- a/apps/custom_page/migrations/0001_initial.py +++ b/apps/custom_page/migrations/0001_initial.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("organization", "0004_alter_organization_template"), + ("organization", "0001_initial"), ] operations = [ 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 similarity index 99% rename from "apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py\342\200\216" rename to apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py index ef66dfd..5fdc957 100644 --- "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 @@ -58,4 +58,4 @@ class Migration(migrations.Migration): model_name="custompage", name="organization", ), - ] \ No newline at end of file + ] diff --git a/apps/organization_monitoring/urls.py b/apps/organization_monitoring/urls.py index 9a2b11c..9a032e9 100644 --- a/apps/organization_monitoring/urls.py +++ b/apps/organization_monitoring/urls.py @@ -21,13 +21,13 @@ name="organization-monitoring-detail", ), path( - "console/organizations/monitoring", + "bootstrap/organizations/monitoring", MonitoringListCreateView.as_view(), - name="console-organization-monitoring-list", + name="bootstrap-organization-monitoring-list", ), path( - "console/organizations/monitoring/", + "bootstrap/organizations/monitoring/", MonitoringDetailView.as_view(), - name="console-organization-monitoring-detail", + name="bootstrap-organization-monitoring-detail", ), ] diff --git a/apps/organization_setting/migrations/0001_initial.py b/apps/organization_setting/migrations/0001_initial.py index e835dc2..987c580 100644 --- a/apps/organization_setting/migrations/0001_initial.py +++ b/apps/organization_setting/migrations/0001_initial.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("organization", "0004_alter_organization_template"), + ("organization", "0001_initial"), ] operations = [ diff --git a/bootstrap_service/celery.py b/bootstrap_service/celery.py index 1c34588..29b4c81 100644 --- a/bootstrap_service/celery.py +++ b/bootstrap_service/celery.py @@ -7,11 +7,7 @@ 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 common.celery.routing import append_unique_task_queues from dotenv import load_dotenv from kombu import Exchange, Queue @@ -21,20 +17,6 @@ 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 = [ diff --git a/bootstrap_service/management/commands/init_organization.py b/bootstrap_service/management/commands/init_organization.py index 3ff3b22..360229e 100644 --- a/bootstrap_service/management/commands/init_organization.py +++ b/bootstrap_service/management/commands/init_organization.py @@ -51,6 +51,9 @@ def add_arguments(self, parser): parser.add_argument( "--owner-email", type=str, help="Owner email", required=False ) + parser.add_argument( + "--org-template", type=str, help="Organization template", required=False + ) parser.add_argument( "--owner-password", type=str, help="Owner password", required=False ) diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index 932e865..87ff045 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -66,7 +66,6 @@ "apps.organization_setting", "apps.organization_monitoring", "apps.billing", - "apps.contact_sales", "apps.organization_roles", "apps.authentication", ] @@ -245,9 +244,6 @@ def silky_intercept_func(request): 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 df759e0..43d8df4 100644 --- a/bootstrap_service/urls.py +++ b/bootstrap_service/urls.py @@ -65,6 +65,5 @@ def health_check(_): 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 a4a63c1..3b049e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ requests==2.33.0 gunicorn==22.0.0 gevent==24.2.1 drf-yasg==1.21.7 -djangorestframework==3.15.2 +djangorestframework==3.17.2 djangorestframework_simplejwt==5.5.1 django-cors-headers==4.4.0 django-filter==24.2