Skip to content

Commit

Permalink
feat(versioning): limit returned number of versions by plan (#4433)
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewelwell authored Oct 28, 2024
1 parent a9dd41f commit 55de839
Show file tree
Hide file tree
Showing 25 changed files with 787 additions and 129 deletions.
5 changes: 5 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
73 changes: 69 additions & 4 deletions api/audit/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,7 +19,7 @@
AuditLogRetrieveSerializer,
AuditLogsQueryParamSerializer,
)
from organisations.models import OrganisationRole
from organisations.models import Organisation, OrganisationRole


@method_decorator(
Expand Down Expand Up @@ -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:
Expand All @@ -64,16 +92,53 @@ 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]

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()
)
3 changes: 3 additions & 0 deletions api/features/versioning/constants.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 34 additions & 1 deletion api/features/versioning/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -55,6 +58,7 @@ class EnvironmentFeatureVersionViewSet(
DestroyModelMixin,
):
permission_classes = [IsAuthenticated, EnvironmentFeatureVersionPermissions]
pagination_class = CustomPagination

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand Down Expand Up @@ -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:
Expand All @@ -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):
"""
Expand Down
2 changes: 2 additions & 0 deletions api/organisations/chargebee/webhook_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
78 changes: 71 additions & 7 deletions api/organisations/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -44,6 +47,7 @@
SUBSCRIPTION_PAYMENT_METHODS,
TRIAL_SUBSCRIPTION_ID,
XERO,
SubscriptionPlanFamily,
)
from organisations.subscriptions.exceptions import (
SubscriptionDoesNotSupportSeatUpgrade,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
)
Expand All @@ -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():
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions api/organisations/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading

0 comments on commit 55de839

Please sign in to comment.