From 55de839fb8882065ddc70465a0d3e7c13235e9ad Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Mon, 28 Oct 2024 12:45:01 +0000 Subject: [PATCH] feat(versioning): limit returned number of versions by plan (#4433) --- api/app/settings/common.py | 5 + api/audit/views.py | 73 ++++++- api/features/versioning/constants.py | 3 + api/features/versioning/views.py | 35 +++- .../chargebee/webhook_handlers.py | 2 + .../0057_limit_audit_and_version_history.py | 23 +++ api/organisations/models.py | 78 ++++++- api/organisations/serializers.py | 3 + api/organisations/subscriptions/constants.py | 20 ++ api/organisations/subscriptions/metadata.py | 51 +++-- .../subscriptions/subscription_service.py | 26 --- api/organisations/tasks.py | 5 +- api/organisations/views.py | 5 +- api/sales_dashboard/forms.py | 24 ++- .../integration/audit/test_audit_logs.py | 19 +- api/tests/unit/audit/test_unit_audit_views.py | 84 ++++++++ .../versioning/test_unit_versioning_views.py | 193 ++++++++++++++++++ .../chargebee/test_unit_chargebee_metadata.py | 2 +- .../test_unit_subscription_service.py | 65 ------ .../test_unit_subscriptions_dataclasses.py | 4 +- .../test_unit_organisations_models.py | 77 +++++++ .../test_unit_organisations_tasks.py | 2 +- .../test_unit_organisations_views.py | 19 ++ api/tests/unit/sales_dashboard/conftest.py | 14 ++ .../test_unit_sales_dashboard_views.py | 84 ++++++++ 25 files changed, 787 insertions(+), 129 deletions(-) create mode 100644 api/features/versioning/constants.py create mode 100644 api/organisations/migrations/0057_limit_audit_and_version_history.py delete mode 100644 api/organisations/subscriptions/subscription_service.py delete mode 100644 api/tests/unit/organisations/subscriptions/test_unit_subscription_service.py create mode 100644 api/tests/unit/sales_dashboard/conftest.py diff --git a/api/app/settings/common.py b/api/app/settings/common.py index bd20132245b2..c1b0d2096371 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1266,3 +1266,8 @@ ORG_SUBSCRIPTION_CANCELLED_ALERT_RECIPIENT_LIST = env.list( "ORG_SUBSCRIPTION_CANCELLED_ALERT_RECIPIENT_LIST", default=[] ) + +# Date on which versioning is released. This is used to give any scale up +# subscriptions created before this date full audit log and versioning +# history. +VERSIONING_RELEASE_DATE = env.date("VERSIONING_RELEASE_DATE", default=None) diff --git a/api/audit/views.py b/api/audit/views.py index 62fadf74d788..0770a3d79ef6 100644 --- a/api/audit/views.py +++ b/api/audit/views.py @@ -1,4 +1,8 @@ +from abc import abstractmethod +from datetime import timedelta + from django.db.models import Q, QuerySet +from django.utils import timezone from django.utils.decorators import method_decorator from drf_yasg.utils import swagger_auto_schema from rest_framework import mixins, viewsets @@ -15,7 +19,7 @@ AuditLogRetrieveSerializer, AuditLogsQueryParamSerializer, ) -from organisations.models import OrganisationRole +from organisations.models import Organisation, OrganisationRole @method_decorator( @@ -44,18 +48,42 @@ def get_queryset(self) -> QuerySet[AuditLog]: if search: q = q & Q(log__icontains=search) - return AuditLog.objects.filter(q).select_related( + queryset = AuditLog.objects.filter(q).select_related( "project", "environment", "author" ) - def _get_base_filters(self) -> Q: - return Q() + return self._apply_visibility_limits(queryset) def get_serializer_class(self): return {"retrieve": AuditLogRetrieveSerializer}.get( self.action, AuditLogListSerializer ) + def _get_base_filters(self) -> Q: # pragma: no cover + return Q() + + def _apply_visibility_limits( + self, queryset: QuerySet[AuditLog] + ) -> QuerySet[AuditLog]: + organisation = self._get_organisation() + if not organisation: + return AuditLog.objects.none() + + subscription_metadata = organisation.subscription.get_subscription_metadata() + if ( + subscription_metadata + and (limit := subscription_metadata.audit_log_visibility_days) is not None + ): + queryset = queryset.filter( + created_date__gte=timezone.now() - timedelta(days=limit) + ) + + return queryset + + @abstractmethod + def _get_organisation(self) -> Organisation | None: + raise NotImplementedError("Must implement _get_organisation()") + class AllAuditLogViewSet(_BaseAuditLogViewSet): def _get_base_filters(self) -> Q: @@ -64,6 +92,25 @@ def _get_base_filters(self) -> Q: project__organisation__userorganisation__role=OrganisationRole.ADMIN, ) + def _get_organisation(self) -> Organisation | None: + """ + This is a bit of a hack but since this endpoint is no longer used (by the UI + at least) we just return the first organisation the user has (most users only + have a single organisation anyway). + + Since this function is only used here for limiting access (by number of days) + to the audit log, the blast radius here is pretty small. + + Since we're applying the base filters to the query set + """ + return ( + self.request.user.organisations.filter( + userorganisation__role=OrganisationRole.ADMIN + ) + .select_related("subscription", "subscription_information_cache") + .first() + ) + class OrganisationAuditLogViewSet(_BaseAuditLogViewSet): permission_classes = [IsAuthenticated, OrganisationAuditLogPermissions] @@ -71,9 +118,27 @@ class OrganisationAuditLogViewSet(_BaseAuditLogViewSet): def _get_base_filters(self) -> Q: return Q(project__organisation__id=self.kwargs["organisation_pk"]) + def _get_organisation(self) -> Organisation | None: + return ( + Organisation.objects.select_related( + "subscription", "subscription_information_cache" + ) + .filter(pk=self.kwargs["organisation_pk"]) + .first() + ) + class ProjectAuditLogViewSet(_BaseAuditLogViewSet): permission_classes = [IsAuthenticated, ProjectAuditLogPermissions] def _get_base_filters(self) -> Q: return Q(project__id=self.kwargs["project_pk"]) + + def _get_organisation(self) -> Organisation | None: + return ( + Organisation.objects.select_related( + "subscription", "subscription_information_cache" + ) + .filter(projects__pk=self.kwargs["project_pk"]) + .first() + ) diff --git a/api/features/versioning/constants.py b/api/features/versioning/constants.py new file mode 100644 index 000000000000..23f72cea940b --- /dev/null +++ b/api/features/versioning/constants.py @@ -0,0 +1,3 @@ +# Constants to define how many days worth of version history should be +# returned in the list endpoint based on the plan of the requesting organisation. +DEFAULT_VERSION_LIMIT_DAYS = 7 diff --git a/api/features/versioning/views.py b/api/features/versioning/views.py index 184fb7266a7f..cef5f5fab9dc 100644 --- a/api/features/versioning/views.py +++ b/api/features/versioning/views.py @@ -1,4 +1,6 @@ -from django.db.models import BooleanField, ExpressionWrapper, Q +from datetime import timedelta + +from django.db.models import BooleanField, ExpressionWrapper, Q, QuerySet from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.decorators import method_decorator @@ -17,6 +19,7 @@ from rest_framework.serializers import Serializer from rest_framework.viewsets import GenericViewSet +from app.pagination import CustomPagination from environments.models import Environment from environments.permissions.constants import VIEW_ENVIRONMENT from features.models import Feature, FeatureState @@ -55,6 +58,7 @@ class EnvironmentFeatureVersionViewSet( DestroyModelMixin, ): permission_classes = [IsAuthenticated, EnvironmentFeatureVersionPermissions] + pagination_class = CustomPagination def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -115,6 +119,9 @@ def get_queryset(self): ) queryset = queryset.filter(_is_live=is_live) + if self.action == "list": + queryset = self._apply_visibility_limits(queryset) + return queryset def perform_create(self, serializer: Serializer) -> None: @@ -139,6 +146,32 @@ def publish(self, request: Request, **kwargs) -> Response: serializer.save(published_by=request.user) return Response(serializer.data) + def _apply_visibility_limits(self, queryset: QuerySet) -> QuerySet: + """ + Filter the given queryset by the visibility limits enforced + by the given organisation's subscription. + """ + subscription = self.environment.project.organisation.subscription + subscription_metadata = subscription.get_subscription_metadata() + if ( + subscription_metadata + and ( + version_limit_days := subscription_metadata.feature_history_visibility_days + ) + is not None + ): + limited_queryset = queryset.filter( + Q(live_from__gte=timezone.now() - timedelta(days=version_limit_days)) + | Q(live_from__isnull=True) + ) + if not limited_queryset.exists(): + # If there are no versions in the visible time frame for the + # given user, we still need to make that we return the live + # version, which will be the first one in the original qs. + return queryset[:1] + return limited_queryset + return queryset + class EnvironmentFeatureVersionRetrieveAPIView(RetrieveAPIView): """ diff --git a/api/organisations/chargebee/webhook_handlers.py b/api/organisations/chargebee/webhook_handlers.py index 1d8d6dd40a30..719f1df8693f 100644 --- a/api/organisations/chargebee/webhook_handlers.py +++ b/api/organisations/chargebee/webhook_handlers.py @@ -157,6 +157,8 @@ def process_subscription(request: Request) -> Response: # noqa: C901 "organisation_id": existing_subscription.organisation_id, "allowed_projects": subscription_metadata.projects, "chargebee_email": subscription_metadata.chargebee_email, + "feature_history_visibility_days": subscription_metadata.feature_history_visibility_days, + "audit_log_visibility_days": subscription_metadata.audit_log_visibility_days, } if "current_term_end" in subscription: diff --git a/api/organisations/migrations/0057_limit_audit_and_version_history.py b/api/organisations/migrations/0057_limit_audit_and_version_history.py new file mode 100644 index 000000000000..b29a79c33ff4 --- /dev/null +++ b/api/organisations/migrations/0057_limit_audit_and_version_history.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2024-08-15 16:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organisations', '0056_create_organisation_breached_grace_period'), + ] + + operations = [ + migrations.AddField( + model_name='organisationsubscriptioninformationcache', + name='audit_log_visibility_days', + field=models.IntegerField(default=0, null=True, blank=True), + ), + migrations.AddField( + model_name='organisationsubscriptioninformationcache', + name='feature_history_visibility_days', + field=models.IntegerField(default=7, null=True, blank=True), + ), + ] diff --git a/api/organisations/models.py b/api/organisations/models.py index 780f24a3c573..3c81188e63bf 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from typing import Any + from core.models import SoftDeleteExportableModel from django.conf import settings from django.core.cache import caches @@ -17,6 +19,7 @@ from simple_history.models import HistoricalRecords from app.utils import is_enterprise, is_saas +from features.versioning.constants import DEFAULT_VERSION_LIMIT_DAYS from integrations.lead_tracking.hubspot.tasks import ( track_hubspot_lead, update_hubspot_active_subscription, @@ -44,6 +47,7 @@ SUBSCRIPTION_PAYMENT_METHODS, TRIAL_SUBSCRIPTION_ID, XERO, + SubscriptionPlanFamily, ) from organisations.subscriptions.exceptions import ( SubscriptionDoesNotSupportSeatUpgrade, @@ -260,7 +264,11 @@ def can_auto_upgrade_seats(self) -> bool: @property def is_free_plan(self) -> bool: - return self.plan == FREE_PLAN_ID + return self.subscription_plan_family == SubscriptionPlanFamily.FREE + + @property + def subscription_plan_family(self) -> SubscriptionPlanFamily: + return SubscriptionPlanFamily.get_by_plan_id(self.plan) @hook(AFTER_SAVE, when="plan", has_changed=True) def update_api_limit_access_block(self): @@ -374,6 +382,10 @@ def _get_subscription_metadata_for_saas(self) -> BaseSubscriptionMetadata: # or for a payment method that is not covered above. In this situation # we want the response to be what is stored in the Django database. # Note that Free plans are caught in the parent method above. + if self.organisation.has_subscription_information_cache(): + return self.organisation.subscription_information_cache.as_base_subscription_metadata( + seats=self.max_seats, api_calls=self.max_api_calls + ) return BaseSubscriptionMetadata( seats=self.max_seats, api_calls=self.max_api_calls ) @@ -382,14 +394,25 @@ def _get_subscription_metadata_for_chargebee(self) -> ChargebeeObjMetadata: if self.organisation.has_subscription_information_cache(): # Getting the data from the subscription information cache because # data is guaranteed to be up to date by using a Chargebee webhook. - return ChargebeeObjMetadata( - seats=self.organisation.subscription_information_cache.allowed_seats, - api_calls=self.organisation.subscription_information_cache.allowed_30d_api_calls, - projects=self.organisation.subscription_information_cache.allowed_projects, - chargebee_email=self.organisation.subscription_information_cache.chargebee_email, + cb_metadata = ( + self.organisation.subscription_information_cache.as_chargebee_subscription_metadata() ) + else: + cb_metadata = get_subscription_metadata_from_id(self.subscription_id) + + if self.subscription_plan_family == SubscriptionPlanFamily.SCALE_UP and ( + settings.VERSIONING_RELEASE_DATE is None + or ( + self.subscription_date is not None + and self.subscription_date < settings.VERSIONING_RELEASE_DATE + ) + ): + # Logic to grandfather old scale up plan customers to give them + # full access to audit log and feature history. + cb_metadata.audit_log_visibility_days = None + cb_metadata.feature_history_visibility_days = None - return get_subscription_metadata_from_id(self.subscription_id) + return cb_metadata def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata: if not is_enterprise(): @@ -399,6 +422,8 @@ def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata seats=self.max_seats, api_calls=self.max_api_calls, projects=None, + audit_log_visibility_days=None, + feature_history_visibility_days=None, ) def add_single_seat(self): @@ -448,12 +473,25 @@ class OrganisationSubscriptionInformationCache(LifecycleModelMixin, models.Model allowed_30d_api_calls = models.IntegerField(default=MAX_API_CALLS_IN_FREE_PLAN) allowed_projects = models.IntegerField(default=1, blank=True, null=True) + audit_log_visibility_days = models.IntegerField(default=0, null=True, blank=True) + feature_history_visibility_days = models.IntegerField( + default=DEFAULT_VERSION_LIMIT_DAYS, null=True, blank=True + ) + chargebee_email = models.EmailField(blank=True, max_length=254, null=True) @hook(AFTER_SAVE, when="allowed_30d_api_calls", has_changed=True) def erase_api_notifications(self): self.organisation.api_usage_notifications.all().delete() + def upgrade_to_enterprise(self, seats: int, api_calls: int): + self.allowed_seats = seats + self.allowed_30d_api_calls = api_calls + + self.allowed_projects = None + self.audit_log_visibility_days = None + self.feature_history_visibility_days = None + def reset_to_defaults(self): """ Resets all limits and CB related data to the defaults, leaving the @@ -465,9 +503,35 @@ def reset_to_defaults(self): self.allowed_seats = MAX_SEATS_IN_FREE_PLAN self.allowed_30d_api_calls = MAX_API_CALLS_IN_FREE_PLAN self.allowed_projects = 1 + self.audit_log_visibility_days = 0 + self.feature_history_visibility_days = DEFAULT_VERSION_LIMIT_DAYS self.chargebee_email = None + def as_base_subscription_metadata(self, **overrides) -> BaseSubscriptionMetadata: + kwargs = { + **self._get_default_subscription_metadata_kwargs(), + **overrides, + } + return BaseSubscriptionMetadata(**kwargs) + + def as_chargebee_subscription_metadata(self, **overrides) -> ChargebeeObjMetadata: + kwargs = { + **self._get_default_subscription_metadata_kwargs(), + "chargebee_email": self.chargebee_email, + **overrides, + } + return ChargebeeObjMetadata(**kwargs) + + def _get_default_subscription_metadata_kwargs(self) -> dict[str, Any]: + return { + "seats": self.allowed_seats, + "api_calls": self.allowed_30d_api_calls, + "projects": self.allowed_projects, + "audit_log_visibility_days": self.audit_log_visibility_days, + "feature_history_visibility_days": self.feature_history_visibility_days, + } + class OrganisationAPIUsageNotification(models.Model): organisation = models.ForeignKey( diff --git a/api/organisations/serializers.py b/api/organisations/serializers.py index a51bf55641b8..fbb09b1eb23c 100644 --- a/api/organisations/serializers.py +++ b/api/organisations/serializers.py @@ -247,6 +247,9 @@ class SubscriptionDetailsSerializer(serializers.Serializer): chargebee_email = serializers.EmailField() + feature_history_visibility_days = serializers.IntegerField(allow_null=True) + audit_log_visibility_days = serializers.IntegerField(allow_null=True) + class OrganisationAPIUsageNotificationSerializer(serializers.Serializer): organisation_id = serializers.IntegerField() diff --git a/api/organisations/subscriptions/constants.py b/api/organisations/subscriptions/constants.py index cd3b8ac5ef23..effd87d182f6 100644 --- a/api/organisations/subscriptions/constants.py +++ b/api/organisations/subscriptions/constants.py @@ -47,8 +47,28 @@ STARTUP = "startup" STARTUP_ANNUAL_V2 = "startup-annual-v2" STARTUP_V2 = "startup-v2" +ENTERPRISE = "enterprise" class SubscriptionCacheEntity(Enum): INFLUX = "INFLUX" CHARGEBEE = "CHARGEBEE" + + +class SubscriptionPlanFamily(Enum): + FREE = "FREE" + START_UP = "START_UP" + SCALE_UP = "SCALE_UP" + ENTERPRISE = "ENTERPRISE" + + @classmethod + def get_by_plan_id(cls, plan_id: str) -> "SubscriptionPlanFamily": + match str(plan_id).replace("-", "").lower(): + case p if p.startswith("scaleup"): + return cls.SCALE_UP + case p if p.startswith("startup"): + return cls.START_UP + case p if p.startswith("enterprise"): + return cls.ENTERPRISE + case _: + return cls.FREE diff --git a/api/organisations/subscriptions/metadata.py b/api/organisations/subscriptions/metadata.py index c6308892be2e..893cb729bc60 100644 --- a/api/organisations/subscriptions/metadata.py +++ b/api/organisations/subscriptions/metadata.py @@ -1,5 +1,7 @@ import typing +from features.versioning.constants import DEFAULT_VERSION_LIMIT_DAYS + class BaseSubscriptionMetadata: payment_source = None @@ -9,13 +11,17 @@ def __init__( seats: int = 0, api_calls: int = 0, projects: typing.Optional[int] = None, - chargebee_email=None, + chargebee_email: str = None, + audit_log_visibility_days: int | None = 0, + feature_history_visibility_days: int | None = DEFAULT_VERSION_LIMIT_DAYS, **kwargs, # allows for extra unknown attrs from CB json metadata ): self.seats = seats self.api_calls = api_calls self.projects = projects self.chargebee_email = chargebee_email + self.audit_log_visibility_days = audit_log_visibility_days + self.feature_history_visibility_days = feature_history_visibility_days def __add__(self, other: "BaseSubscriptionMetadata"): if self.payment_source != other.payment_source: @@ -23,25 +29,27 @@ def __add__(self, other: "BaseSubscriptionMetadata"): "Cannot add SubscriptionMetadata from multiple payment sources." ) - if self.projects is not None and other.projects is not None: - projects = self.projects + other.projects - elif self.projects is None and other.projects is not None: - projects = other.projects - elif other.projects is None and self.projects is not None: - projects = self.projects - else: - projects = None - return self.__class__( seats=self.seats + other.seats, api_calls=self.api_calls + other.api_calls, - projects=projects, + projects=_add_nullable_limit_attributes(self.projects, other.projects), chargebee_email=self.chargebee_email, + audit_log_visibility_days=_add_nullable_limit_attributes( + self.audit_log_visibility_days, + other.audit_log_visibility_days, + addition_function=max, + ), + feature_history_visibility_days=_add_nullable_limit_attributes( + self.feature_history_visibility_days, + other.feature_history_visibility_days, + addition_function=max, + ), ) def __str__(self): return ( - "%s Subscription Metadata (seats: %d, api_calls: %d, projects: %s, chargebee_email: %s)" + "%s Subscription Metadata (seats: %d, api_calls: %d, projects: %s, " + "chargebee_email: %s, audit_log_visibility_days: %s, feature_history_visibility_days: %s)" % ( ( self.payment_source.title() @@ -52,6 +60,8 @@ def __str__(self): self.api_calls, str(self.projects) if self.projects is not None else "no limit", self.chargebee_email, + self.audit_log_visibility_days, + self.feature_history_visibility_days, ) ) @@ -65,4 +75,21 @@ def __eq__(self, other: "BaseSubscriptionMetadata"): and self.projects == other.projects and self.payment_source == other.payment_source and self.chargebee_email == other.chargebee_email + and self.audit_log_visibility_days == other.audit_log_visibility_days + and self.feature_history_visibility_days + == other.feature_history_visibility_days ) + + +def _add_nullable_limit_attributes( + first: int | None, + second: int | None, + addition_function: typing.Callable[[typing.Tuple[int, int]], int] = sum, +) -> int | None: + """ + Add 2 nullable attributes where None implies no limit (and hence is the maximum + value). Based on this, if either attribute is None, we return None. + """ + if first is None or second is None: + return None + return addition_function((first, second)) diff --git a/api/organisations/subscriptions/subscription_service.py b/api/organisations/subscriptions/subscription_service.py deleted file mode 100644 index eb888e94126f..000000000000 --- a/api/organisations/subscriptions/subscription_service.py +++ /dev/null @@ -1,26 +0,0 @@ -from organisations.chargebee import get_subscription_metadata_from_id -from organisations.models import Organisation -from organisations.subscriptions.xero.metadata import XeroSubscriptionMetadata - -from .constants import CHARGEBEE, SUBSCRIPTION_DEFAULT_LIMITS, XERO -from .metadata import BaseSubscriptionMetadata - - -def get_subscription_metadata(organisation: Organisation) -> BaseSubscriptionMetadata: - max_api_calls, max_seats, max_projects = SUBSCRIPTION_DEFAULT_LIMITS - subscription_metadata = BaseSubscriptionMetadata( - seats=max_seats, api_calls=max_api_calls, projects=max_projects - ) - if organisation.subscription.payment_method == CHARGEBEE: - chargebee_subscription_metadata = get_subscription_metadata_from_id( - organisation.subscription.subscription_id - ) - if chargebee_subscription_metadata is not None: - subscription_metadata = chargebee_subscription_metadata - elif organisation.subscription.payment_method == XERO: - subscription_metadata = XeroSubscriptionMetadata( - seats=organisation.subscription.max_seats, - api_calls=organisation.subscription.max_api_calls, - ) - - return subscription_metadata diff --git a/api/organisations/tasks.py b/api/organisations/tasks.py index 6fd888aefd03..ae76c28bd132 100644 --- a/api/organisations/tasks.py +++ b/api/organisations/tasks.py @@ -27,9 +27,6 @@ Subscription, ) from organisations.subscriptions.constants import FREE_PLAN_ID -from organisations.subscriptions.subscription_service import ( - get_subscription_metadata, -) from users.models import FFAdminUser from .constants import ( @@ -56,7 +53,7 @@ def send_org_over_limit_alert(organisation_id: int) -> None: organisation = Organisation.objects.get(id=organisation_id) - subscription_metadata = get_subscription_metadata(organisation) + subscription_metadata = organisation.subscription.get_subscription_metadata() FFAdminUser.send_alert_to_admin_users( subject=ALERT_EMAIL_SUBJECT, message=ALERT_EMAIL_MESSAGE diff --git a/api/organisations/views.py b/api/organisations/views.py index 35afd1b66095..33bd581441a9 100644 --- a/api/organisations/views.py +++ b/api/organisations/views.py @@ -190,7 +190,10 @@ def update_subscription(self, request, pk): def get_subscription_metadata(self, request, pk): organisation = self.get_object() subscription_details = organisation.subscription.get_subscription_metadata() - serializer = self.get_serializer(instance=subscription_details) + serializer = self.get_serializer( + instance=subscription_details, + context={"subscription": organisation.subscription}, + ) return Response(serializer.data) @action(detail=True, methods=["GET"], url_path="portal-url") diff --git a/api/sales_dashboard/forms.py b/api/sales_dashboard/forms.py index 4314866f7c90..72b5046cd275 100644 --- a/api/sales_dashboard/forms.py +++ b/api/sales_dashboard/forms.py @@ -7,7 +7,10 @@ from environments.models import Environment from features.models import Feature -from organisations.models import Organisation +from organisations.models import ( + Organisation, + OrganisationSubscriptionInformationCache, +) from organisations.subscriptions.constants import ( FREE_PLAN_ID, MAX_API_CALLS_IN_FREE_PLAN, @@ -41,14 +44,23 @@ class StartTrialForm(forms.Form): def save(self, organisation: Organisation, commit: bool = True) -> Organisation: subscription = organisation.subscription - subscription.max_seats = self.cleaned_data["max_seats"] - subscription.max_api_calls = self.cleaned_data["max_api_calls"] + max_seats = self.cleaned_data["max_seats"] + max_api_calls = self.cleaned_data["max_api_calls"] + + subscription.max_seats = max_seats + subscription.max_api_calls = max_api_calls subscription.subscription_id = TRIAL_SUBSCRIPTION_ID subscription.customer_id = TRIAL_SUBSCRIPTION_ID subscription.plan = "enterprise-saas-monthly-v2" + osic = getattr( + organisation, "subscription_information_cache", None + ) or OrganisationSubscriptionInformationCache(organisation=organisation) + osic.upgrade_to_enterprise(seats=max_seats, api_calls=max_api_calls) + if commit: subscription.save() + osic.save() return organisation @@ -64,8 +76,14 @@ def save(self, organisation: Organisation, commit: bool = True) -> Organisation: subscription.plan = FREE_PLAN_ID subscription.save() + osic = getattr( + organisation, "subscription_information_cache", None + ) or OrganisationSubscriptionInformationCache(organisation=organisation) + osic.reset_to_defaults() + if commit: subscription.save() + osic.save() return organisation diff --git a/api/tests/integration/audit/test_audit_logs.py b/api/tests/integration/audit/test_audit_logs.py index 9ee71f325486..7156004d9f55 100644 --- a/api/tests/integration/audit/test_audit_logs.py +++ b/api/tests/integration/audit/test_audit_logs.py @@ -1,11 +1,26 @@ import json +import pytest from django.urls import reverse +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient +from organisations.subscriptions.metadata import BaseSubscriptionMetadata -def test_audit_logs_only_makes_two_queries( + +@pytest.fixture(autouse=True) +def _subscription_metadata(mocker: MockerFixture) -> None: + metadata = BaseSubscriptionMetadata( + audit_log_visibility_days=None, + ) + mocker.patch( + "organisations.models.Subscription.get_subscription_metadata", + return_value=metadata, + ) + + +def test_get_audit_logs_makes_expected_queries( admin_client, project, environment, @@ -15,7 +30,7 @@ def test_audit_logs_only_makes_two_queries( ): url = reverse("api-v1:audit-list") - with django_assert_num_queries(2): + with django_assert_num_queries(3): res = admin_client.get(url, {"project": project}) assert res.status_code == status.HTTP_200_OK diff --git a/api/tests/unit/audit/test_unit_audit_views.py b/api/tests/unit/audit/test_unit_audit_views.py index 9c10d46dd4f1..aa46f3c8d937 100644 --- a/api/tests/unit/audit/test_unit_audit_views.py +++ b/api/tests/unit/audit/test_unit_audit_views.py @@ -1,7 +1,11 @@ import typing +from datetime import timedelta +import pytest from django.db.models import Model from django.urls import reverse +from django.utils import timezone +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient @@ -12,10 +16,23 @@ from features.models import Feature from features.versioning.models import EnvironmentFeatureVersion from organisations.models import Organisation, OrganisationRole +from organisations.subscriptions.metadata import BaseSubscriptionMetadata from projects.models import Project from users.models import FFAdminUser +@pytest.fixture(autouse=True) +def subscription_metadata(mocker: MockerFixture) -> None: + metadata = BaseSubscriptionMetadata( + audit_log_visibility_days=None, + ) + mocker.patch( + "organisations.models.Subscription.get_subscription_metadata", + return_value=metadata, + ) + return metadata + + def test_audit_log_can_be_filtered_by_environments( admin_client: APIClient, project: Project, environment: Environment ) -> None: @@ -186,3 +203,70 @@ def test_retrieve_environment_feature_version_published_audit_log_record_include response_json["log"] == ENVIRONMENT_FEATURE_VERSION_PUBLISHED_MESSAGE % feature.name ) + + +def test_list_audit_log_for_project_limits_logs_returned_for_non_enterprise( + subscription_metadata: BaseSubscriptionMetadata, + project: Project, + admin_client: APIClient, +) -> None: + # Given + url = reverse("api-v1:projects:project-audit-list", args=[project.id]) + + subscription_metadata.audit_log_visibility_days = 1 + + now = timezone.now() + two_days_ago = now - timedelta(days=2) + + AuditLog.objects.create( + project=project, log="Something that happened today", created_date=now + ) + AuditLog.objects.create( + project=project, + log="Something that happened 2 days ago", + created_date=two_days_ago, + ) + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["count"] == 1 + assert response_json["results"][0]["log"] == "Something that happened today" + + +def test_list_audit_log_for_organisation_limits_logs_returned_for_non_enterprise( + subscription_metadata: BaseSubscriptionMetadata, + organisation: Organisation, + project: Project, + admin_client: APIClient, +) -> None: + # Given + url = reverse("api-v1:organisations:audit-log-list", args=[organisation.id]) + + subscription_metadata.audit_log_visibility_days = 1 + + now = timezone.now() + two_days_ago = now - timedelta(days=2) + + AuditLog.objects.create( + project=project, log="Something that happened today", created_date=now + ) + AuditLog.objects.create( + project=project, + log="Something that happened 2 days ago", + created_date=two_days_ago, + ) + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["count"] == 1 + assert response_json["results"][0]["log"] == "Something that happened today" diff --git a/api/tests/unit/features/versioning/test_unit_versioning_views.py b/api/tests/unit/features/versioning/test_unit_versioning_views.py index df30cbaf314f..e0da742be6c0 100644 --- a/api/tests/unit/features/versioning/test_unit_versioning_views.py +++ b/api/tests/unit/features/versioning/test_unit_versioning_views.py @@ -7,6 +7,8 @@ from django.urls import reverse from django.utils import timezone from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient @@ -24,7 +26,13 @@ ) from features.models import Feature, FeatureSegment, FeatureState from features.multivariate.models import MultivariateFeatureOption +from features.versioning.constants import DEFAULT_VERSION_LIMIT_DAYS from features.versioning.models import EnvironmentFeatureVersion +from organisations.models import ( + OrganisationSubscriptionInformationCache, + Subscription, +) +from organisations.subscriptions.constants import SubscriptionPlanFamily from projects.models import Project from projects.permissions import VIEW_PROJECT from segments.models import Segment @@ -1518,3 +1526,188 @@ def test_cannot_create_new_version_for_environment_not_enabled_for_versioning_v2 assert response.json() == { "environment": "Environment must use v2 feature versioning." } + + +@pytest.mark.freeze_time(now - timedelta(days=DEFAULT_VERSION_LIMIT_DAYS + 1)) +@pytest.mark.parametrize( + "plan_id, is_saas", + (("free", True), ("free", False), ("startup", True), ("scale-up", True)), +) +def test_list_versions_only_returns_allowed_amount_for_non_enterprise_plan( + feature: Feature, + environment_v2_versioning: Environment, + staff_user: FFAdminUser, + staff_client: APIClient, + with_environment_permissions: WithEnvironmentPermissionsCallable, + with_project_permissions: WithProjectPermissionsCallable, + subscription: Subscription, + freezer: FrozenDateTimeFactory, + plan_id: str, + is_saas: bool, + mocker: MockerFixture, +) -> None: + # Given + with_environment_permissions([VIEW_ENVIRONMENT]) + with_project_permissions([VIEW_PROJECT]) + + mocker.patch("organisations.models.is_saas", return_value=is_saas) + + url = reverse( + "api-v1:versioning:environment-feature-versions-list", + args=[environment_v2_versioning.id, feature.id], + ) + + subscription.plan = plan_id + subscription.save() + + # First, let's create some versions at the frozen time which is + # outside the limit allowed when using a non-enterprise plan + outside_limit_versions = [] + for _ in range(3): + version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + version.publish(staff_user) + outside_limit_versions.append(version) + + # Now let's jump to the current time and create some versions which + # are inside the limit when using a non-enterprise plan + freezer.move_to(now) + + inside_limit_versions = [] + for _ in range(3): + version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + version.publish(staff_user) + inside_limit_versions.append(version) + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["count"] == 3 + assert {v["uuid"] for v in response_json["results"]} == { + str(v.uuid) for v in inside_limit_versions + } + + +@pytest.mark.freeze_time(now - timedelta(days=DEFAULT_VERSION_LIMIT_DAYS + 1)) +def test_list_versions_always_returns_current_version_even_if_outside_limit( + feature: Feature, + environment_v2_versioning: Environment, + staff_user: FFAdminUser, + staff_client: APIClient, + with_environment_permissions: WithEnvironmentPermissionsCallable, + with_project_permissions: WithProjectPermissionsCallable, + subscription: Subscription, + freezer: FrozenDateTimeFactory, +) -> None: + # Given + with_environment_permissions([VIEW_ENVIRONMENT]) + with_project_permissions([VIEW_PROJECT]) + + url = reverse( + "api-v1:versioning:environment-feature-versions-list", + args=[environment_v2_versioning.id, feature.id], + ) + + assert subscription.subscription_plan_family == SubscriptionPlanFamily.FREE + + # First, let's create a new version, after the initial version, but + # still outside the limit allowed when using a non-enterprise plan + freezer.move_to(timezone.now() + timedelta(minutes=5)) + latest_version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + latest_version.publish(staff_user) + + # When + # we jump to the current time and retrieve the versions + freezer.move_to(now) + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["count"] == 1 + assert response_json["results"][0]["uuid"] == str(latest_version.uuid) + + +@pytest.mark.freeze_time(now - timedelta(days=DEFAULT_VERSION_LIMIT_DAYS + 1)) +@pytest.mark.parametrize("is_saas", (True, False)) +def test_list_versions_returns_all_versions_for_enterprise_plan( + feature: Feature, + environment_v2_versioning: Environment, + staff_user: FFAdminUser, + staff_client: APIClient, + with_environment_permissions: WithEnvironmentPermissionsCallable, + with_project_permissions: WithProjectPermissionsCallable, + subscription: Subscription, + freezer: FrozenDateTimeFactory, + is_saas: bool, + mocker: MockerFixture, +) -> None: + # Given + with_environment_permissions([VIEW_ENVIRONMENT]) + with_project_permissions([VIEW_PROJECT]) + + url = reverse( + "api-v1:versioning:environment-feature-versions-list", + args=[environment_v2_versioning.id, feature.id], + ) + + mocker.patch("organisations.models.is_saas", return_value=is_saas) + mocker.patch("organisations.models.is_enterprise", return_value=not is_saas) + + # Let's set the subscription plan as start up + subscription.plan = "enterprise" + subscription.save() + + if is_saas: + OrganisationSubscriptionInformationCache.objects.update_or_create( + organisation=subscription.organisation, + defaults={"feature_history_visibility_days": None}, + ) + + initial_version = EnvironmentFeatureVersion.objects.get( + feature=feature, environment=environment_v2_versioning + ) + + # First, let's create some versions at the frozen time which is + # outside the limit allowed when using the scale up plan (but + # shouldn't matter to the enterprise plan. + all_versions = [] + for _ in range(3): + version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + version.publish(staff_user) + all_versions.append(version) + + # Now let's jump to the current time and create some versions which + # are inside the limit when using the startup plan + freezer.move_to(now) + + for _ in range(3): + version = EnvironmentFeatureVersion.objects.create( + environment=environment_v2_versioning, feature=feature + ) + version.publish(staff_user) + all_versions.append(version) + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + response_json = response.json() + assert response_json["count"] == 7 # we created 6, plus the original version + assert {v["uuid"] for v in response_json["results"]} == { + str(v.uuid) for v in [initial_version, *all_versions] + } diff --git a/api/tests/unit/organisations/chargebee/test_unit_chargebee_metadata.py b/api/tests/unit/organisations/chargebee/test_unit_chargebee_metadata.py index 5fa51942793e..a17f5ed49764 100644 --- a/api/tests/unit/organisations/chargebee/test_unit_chargebee_metadata.py +++ b/api/tests/unit/organisations/chargebee/test_unit_chargebee_metadata.py @@ -12,7 +12,7 @@ def test_add_chargebee_object_meta_data(): # Then assert added_chargebee_obj_metadata.seats == 30 assert added_chargebee_obj_metadata.api_calls == 300 - assert added_chargebee_obj_metadata.projects == 100 + assert added_chargebee_obj_metadata.projects is None def test_multiply_chargebee_object_metadata(): diff --git a/api/tests/unit/organisations/subscriptions/test_unit_subscription_service.py b/api/tests/unit/organisations/subscriptions/test_unit_subscription_service.py deleted file mode 100644 index d286decd680a..000000000000 --- a/api/tests/unit/organisations/subscriptions/test_unit_subscription_service.py +++ /dev/null @@ -1,65 +0,0 @@ -from django.conf import settings - -from organisations.chargebee.metadata import ChargebeeObjMetadata -from organisations.subscriptions.constants import ( - CHARGEBEE, - MAX_API_CALLS_IN_FREE_PLAN, - MAX_SEATS_IN_FREE_PLAN, - XERO, -) -from organisations.subscriptions.subscription_service import ( - get_subscription_metadata, -) - - -def test_get_subscription_metadata_returns_default_values_if_org_does_not_have_subscription( - organisation, -): - # When - subscription_metadata = get_subscription_metadata(organisation) - - # Then - assert subscription_metadata.api_calls == MAX_API_CALLS_IN_FREE_PLAN - assert subscription_metadata.seats == MAX_SEATS_IN_FREE_PLAN - assert subscription_metadata.projects == settings.MAX_PROJECTS_IN_FREE_PLAN - assert subscription_metadata.payment_source is None - - -def test_get_subscription_metadata_uses_chargebee_data_if_chargebee_subscription_exists( - organisation, chargebee_subscription, mocker -): - # Given - seats = 10 - projects = 20 - api_calls = 30 - mocked_get_chargebee_subscription_metadata = mocker.patch( - "organisations.subscriptions.subscription_service.get_subscription_metadata_from_id", - autospec=True, - return_value=ChargebeeObjMetadata( - seats=seats, projects=projects, api_calls=api_calls - ), - ) - # When - subscription_metadata = get_subscription_metadata(organisation) - - # Then - assert subscription_metadata.api_calls == api_calls - assert subscription_metadata.seats == seats - assert subscription_metadata.projects == projects - mocked_get_chargebee_subscription_metadata.assert_called_once_with( - chargebee_subscription.subscription_id - ) - assert subscription_metadata.payment_source == CHARGEBEE - - -def test_get_subscription_metadata_uses_metadata_from_subscription_for_non_chargebee_subscription( - organisation, xero_subscription -): - # When - subscription_metadata = get_subscription_metadata(organisation) - - # Then - assert subscription_metadata.api_calls == xero_subscription.max_api_calls - assert subscription_metadata.seats == xero_subscription.max_seats - assert subscription_metadata.projects is None - assert subscription_metadata.payment_source == XERO diff --git a/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_dataclasses.py b/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_dataclasses.py index 611b7979b8dd..d8a4a1e5b629 100644 --- a/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_dataclasses.py +++ b/api/tests/unit/organisations/subscriptions/test_unit_subscriptions_dataclasses.py @@ -40,12 +40,12 @@ def test_base_subscription_metadata_add_raises_error_if_not_matching_payment_sou ( SourceASubscriptionMetadata(seats=1, api_calls=50000, projects=1), SourceASubscriptionMetadata(seats=1, api_calls=50000), - SourceASubscriptionMetadata(seats=2, api_calls=100000, projects=1), + SourceASubscriptionMetadata(seats=2, api_calls=100000, projects=None), ), ( SourceASubscriptionMetadata(seats=1, api_calls=50000), SourceASubscriptionMetadata(seats=1, api_calls=50000, projects=1), - SourceASubscriptionMetadata(seats=2, api_calls=100000, projects=1), + SourceASubscriptionMetadata(seats=2, api_calls=100000, projects=None), ), ), ) diff --git a/api/tests/unit/organisations/test_unit_organisations_models.py b/api/tests/unit/organisations/test_unit_organisations_models.py index 85466cd1aaa5..aa76002cd5cd 100644 --- a/api/tests/unit/organisations/test_unit_organisations_models.py +++ b/api/tests/unit/organisations/test_unit_organisations_models.py @@ -4,6 +4,7 @@ import pytest from django.conf import settings from django.utils import timezone +from pytest_django.fixtures import SettingsWrapper from pytest_mock import MockerFixture from environments.models import Environment @@ -22,6 +23,7 @@ MAX_SEATS_IN_FREE_PLAN, TRIAL_SUBSCRIPTION_ID, XERO, + SubscriptionPlanFamily, ) from organisations.subscriptions.exceptions import ( SubscriptionDoesNotSupportSeatUpgrade, @@ -222,11 +224,15 @@ def test_organisation_is_paid_returns_false_if_cancelled_subscription_exists( def test_organisation_subscription_get_subscription_metadata_returns_cb_metadata_for_cb_subscription( organisation: Organisation, mocker: MockerFixture, + settings: SettingsWrapper, ): # Given seats = 10 api_calls = 50000000 projects = 10 + + settings.VERSIONING_RELEASE_DATE = timezone.now() - timedelta(days=1) + OrganisationSubscriptionInformationCache.objects.create( organisation=organisation, allowed_seats=seats, @@ -251,6 +257,55 @@ def test_organisation_subscription_get_subscription_metadata_returns_cb_metadata assert subscription_metadata == expected_metadata +def test_get_subscription_metadata_returns_unlimited_values_for_audit_and_versions_when_released( + organisation: Organisation, + mocker: MockerFixture, + settings: SettingsWrapper, +): + # Given + seats = 10 + api_calls = 50000000 + projects = 10 + now = timezone.now() + yesterday = now - timedelta(days=1) + two_days_ago = now - timedelta(days=2) + + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + allowed_seats=seats, + allowed_30d_api_calls=api_calls, + allowed_projects=projects, + # values from here should be overridden + audit_log_visibility_days=30, + feature_history_visibility_days=30, + ) + expected_metadata = ChargebeeObjMetadata( + seats=seats, + api_calls=api_calls, + projects=projects, + # the following values are patched on based on the + # VERSIONING_RELEASE_DATE setting + audit_log_visibility_days=None, + feature_history_visibility_days=None, + ) + mocker.patch("organisations.models.is_saas", return_value=True) + Subscription.objects.filter(organisation=organisation).update( + plan="scale-up-v2", + subscription_id="subscription-id", + payment_method=CHARGEBEE, + subscription_date=two_days_ago, + ) + organisation.subscription.refresh_from_db() + + settings.VERSIONING_RELEASE_DATE = yesterday + + # When + subscription_metadata = organisation.subscription.get_subscription_metadata() + + # Then + assert subscription_metadata == expected_metadata + + def test_organisation_subscription_get_subscription_metadata_returns_xero_metadata_for_xero_sub( mocker: MockerFixture, ): @@ -514,3 +569,25 @@ def test_organisation_creates_subscription_cache( organisation.subscription_information_cache.allowed_30d_api_calls == MAX_API_CALLS_IN_FREE_PLAN ) + + +@pytest.mark.parametrize( + "plan_id, expected_plan_family", + ( + ("free", SubscriptionPlanFamily.FREE), + ("enterprise", SubscriptionPlanFamily.ENTERPRISE), + ("enterprise-semiannual", SubscriptionPlanFamily.ENTERPRISE), + ("scale-up", SubscriptionPlanFamily.SCALE_UP), + ("scaleup", SubscriptionPlanFamily.SCALE_UP), + ("scale-up-v2", SubscriptionPlanFamily.SCALE_UP), + ("scale-up-v2-annual", SubscriptionPlanFamily.SCALE_UP), + ("startup", SubscriptionPlanFamily.START_UP), + ("start-up", SubscriptionPlanFamily.START_UP), + ("start-up-v2", SubscriptionPlanFamily.START_UP), + ("start-up-v2-annual", SubscriptionPlanFamily.START_UP), + ), +) +def test_subscription_plan_family( + plan_id: str, expected_plan_family: SubscriptionPlanFamily +) -> None: + assert Subscription(plan=plan_id).subscription_plan_family == expected_plan_family diff --git a/api/tests/unit/organisations/test_unit_organisations_tasks.py b/api/tests/unit/organisations/test_unit_organisations_tasks.py index 760c8135d82f..a96d7c6ce1f6 100644 --- a/api/tests/unit/organisations/test_unit_organisations_tasks.py +++ b/api/tests/unit/organisations/test_unit_organisations_tasks.py @@ -85,7 +85,7 @@ def test_send_org_over_limit_alert_for_organisation_with_subscription( mocked_ffadmin_user = mocker.patch("organisations.tasks.FFAdminUser") max_seats = 10 mocker.patch( - "organisations.tasks.get_subscription_metadata", + "organisations.models.Subscription.get_subscription_metadata", return_value=SubscriptionMetadata(seats=max_seats), ) diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index e10eac5f0604..d55b28e4fb20 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -23,6 +23,7 @@ from environments.models import Environment from environments.permissions.models import UserEnvironmentPermission from features.models import Feature +from features.versioning.constants import DEFAULT_VERSION_LIMIT_DAYS from integrations.lead_tracking.hubspot.constants import HUBSPOT_COOKIE_NAME from organisations.chargebee.metadata import ChargebeeObjMetadata from organisations.invites.models import Invite @@ -918,12 +919,18 @@ def test_get_subscription_metadata_when_subscription_information_cache_exist( expected_api_calls = 100 expected_chargebee_email = "test@example.com" + settings.VERSIONING_RELEASE_DATE = timezone.now() - timedelta(days=1) + expected_feature_history_visibility_days = 30 + expected_audit_log_visibility_days = 30 + OrganisationSubscriptionInformationCache.objects.create( organisation=organisation, allowed_seats=expected_seats, allowed_projects=expected_projects, allowed_30d_api_calls=expected_api_calls, chargebee_email=expected_chargebee_email, + feature_history_visibility_days=expected_feature_history_visibility_days, + audit_log_visibility_days=expected_audit_log_visibility_days, ) url = reverse( @@ -944,6 +951,8 @@ def test_get_subscription_metadata_when_subscription_information_cache_exist( "max_api_calls": expected_api_calls, "payment_source": CHARGEBEE, "chargebee_email": expected_chargebee_email, + "feature_history_visibility_days": expected_feature_history_visibility_days, + "audit_log_visibility_days": expected_audit_log_visibility_days, } @@ -959,6 +968,10 @@ def test_get_subscription_metadata_when_subscription_information_cache_does_not_ expected_api_calls = 100 expected_chargebee_email = "test@example.com" + settings.VERSIONING_RELEASE_DATE = timezone.now() - timedelta(days=1) + expected_feature_history_visibility_days = DEFAULT_VERSION_LIMIT_DAYS + expected_audit_log_visibility_days = 0 + mocker.patch("organisations.models.is_saas", return_value=True) get_subscription_metadata = mocker.patch( "organisations.models.get_subscription_metadata_from_id", @@ -986,6 +999,8 @@ def test_get_subscription_metadata_when_subscription_information_cache_does_not_ "max_api_calls": expected_api_calls, "payment_source": CHARGEBEE, "chargebee_email": expected_chargebee_email, + "feature_history_visibility_days": expected_feature_history_visibility_days, + "audit_log_visibility_days": expected_audit_log_visibility_days, } get_subscription_metadata.assert_called_once_with( chargebee_subscription.subscription_id @@ -1018,6 +1033,8 @@ def test_get_subscription_metadata_returns_200_if_the_organisation_have_no_paid_ "max_projects": settings.MAX_PROJECTS_IN_FREE_PLAN, "max_seats": 1, "payment_source": None, + "feature_history_visibility_days": DEFAULT_VERSION_LIMIT_DAYS, + "audit_log_visibility_days": 0, } get_subscription_metadata.assert_not_called() @@ -1044,6 +1061,8 @@ def test_get_subscription_metadata_returns_defaults_if_chargebee_error( "max_projects": settings.MAX_PROJECTS_IN_FREE_PLAN, "payment_source": None, "chargebee_email": None, + "feature_history_visibility_days": DEFAULT_VERSION_LIMIT_DAYS, + "audit_log_visibility_days": 0, } diff --git a/api/tests/unit/sales_dashboard/conftest.py b/api/tests/unit/sales_dashboard/conftest.py new file mode 100644 index 000000000000..86fbc55a66b5 --- /dev/null +++ b/api/tests/unit/sales_dashboard/conftest.py @@ -0,0 +1,14 @@ +import pytest + +from organisations.models import Organisation +from sales_dashboard.forms import StartTrialForm + + +@pytest.fixture() +def in_trial_organisation(organisation: Organisation) -> Organisation: + form = StartTrialForm(data={"max_seats": 20, "max_api_calls": 5_000_000}) + assert form.is_valid() + form.save(organisation) + organisation.refresh_from_db() + organisation.subscription_information_cache.refresh_from_db() + return organisation diff --git a/api/tests/unit/sales_dashboard/test_unit_sales_dashboard_views.py b/api/tests/unit/sales_dashboard/test_unit_sales_dashboard_views.py index 163f101c638a..dd402ec7aac5 100644 --- a/api/tests/unit/sales_dashboard/test_unit_sales_dashboard_views.py +++ b/api/tests/unit/sales_dashboard/test_unit_sales_dashboard_views.py @@ -8,11 +8,18 @@ from pytest_mock import MockerFixture from rest_framework.test import APIClient +from features.versioning.constants import DEFAULT_VERSION_LIMIT_DAYS from organisations.models import ( Organisation, OrganisationSubscriptionInformationCache, Subscription, ) +from organisations.subscriptions.constants import ( + FREE_PLAN_ID, + MAX_API_CALLS_IN_FREE_PLAN, + MAX_SEATS_IN_FREE_PLAN, + TRIAL_SUBSCRIPTION_ID, +) from sales_dashboard.views import OrganisationList from users.models import FFAdminUser @@ -238,3 +245,80 @@ def test_post_email_usage_fails_if_not_staff( # Then assert response.status_code == 302 assert response.url == "/admin/login/?next=/sales-dashboard/email-usage/" + + +def test_start_trial( + organisation: Organisation, + client: Client, + admin_user: FFAdminUser, +) -> None: + # Given + url = reverse("sales_dashboard:organisation_start_trial", args=[organisation.id]) + client.force_login(admin_user) + + seats = 20 + api_calls = 5_000_000 + + # When + response = client.post(url, data={"max_seats": seats, "max_api_calls": api_calls}) + + # Then + assert response.status_code == 302 + + subscription = Subscription.objects.get(organisation=organisation) + assert subscription.subscription_id == TRIAL_SUBSCRIPTION_ID + assert subscription.customer_id == TRIAL_SUBSCRIPTION_ID + assert subscription.plan == "enterprise-saas-monthly-v2" + assert subscription.max_seats == seats + assert subscription.max_api_calls == api_calls + + subscription_information_cache = ( + OrganisationSubscriptionInformationCache.objects.get(organisation=organisation) + ) + assert subscription_information_cache.allowed_seats == seats + assert subscription_information_cache.allowed_30d_api_calls == api_calls + assert subscription_information_cache.allowed_projects is None + assert subscription_information_cache.audit_log_visibility_days is None + assert subscription_information_cache.feature_history_visibility_days is None + + +def test_end_trial( + in_trial_organisation: Organisation, + client: Client, + admin_user: FFAdminUser, +) -> None: + # Given + url = reverse( + "sales_dashboard:organisation_end_trial", args=[in_trial_organisation.id] + ) + client.force_login(admin_user) + + # When + response = client.post(url) + + # Then + assert response.status_code == 302 + + subscription = Subscription.objects.get(organisation=in_trial_organisation) + assert subscription.subscription_id == "" + assert subscription.customer_id == "" + assert subscription.plan == FREE_PLAN_ID + assert subscription.max_seats == MAX_SEATS_IN_FREE_PLAN + assert subscription.max_api_calls == MAX_API_CALLS_IN_FREE_PLAN + + subscription_information_cache = ( + OrganisationSubscriptionInformationCache.objects.get( + organisation=in_trial_organisation + ) + ) + assert subscription_information_cache.allowed_seats == MAX_SEATS_IN_FREE_PLAN + assert ( + subscription_information_cache.allowed_30d_api_calls + == MAX_API_CALLS_IN_FREE_PLAN + ) + assert subscription_information_cache.allowed_projects == 1 + assert subscription_information_cache.audit_log_visibility_days == 0 + assert ( + subscription_information_cache.feature_history_visibility_days + == DEFAULT_VERSION_LIMIT_DAYS + )