Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(versioning): limit returned number of versions by plan #4433

Merged
merged 32 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
16c070c
Add logic to get 'subscription plan family'
matthewelwell Jul 31, 2024
1d39c3f
Restrict number of versions returned based on plan
matthewelwell Jul 31, 2024
5cf3eca
Add test
matthewelwell Jul 31, 2024
8884de4
Merge branch 'refs/heads/main' into feat(versioning)/configure-num-ve…
matthewelwell Aug 7, 2024
2961459
Merge branch 'refs/heads/main' into feat(versioning)/configure-num-ve…
matthewelwell Aug 7, 2024
04192f9
Add version limit to subscription metadata endpoint
matthewelwell Aug 7, 2024
0f976da
Update limits
matthewelwell Aug 7, 2024
5335af0
Merge branch 'refs/heads/main' into feat(versioning)/configure-num-ve…
matthewelwell Aug 8, 2024
ca7f632
Fix tests
matthewelwell Aug 8, 2024
83592c8
Merge branch 'refs/heads/main' into feat(versioning)/configure-num-ve…
matthewelwell Aug 14, 2024
abba50e
Use days instead of number of versions
matthewelwell Aug 14, 2024
8f48f7f
Delete subscription_service and replace with method on subscription m…
matthewelwell Aug 15, 2024
c12f42c
Move version limit to be handled by chargebee / subscription metadata
matthewelwell Aug 15, 2024
6900cb7
Various fixes and allow trial subscriptions to use unlimited history
matthewelwell Aug 16, 2024
d5dac66
Refactor to use a private method for readability
matthewelwell Aug 16, 2024
75c0636
Add pragma: no cover to abstract method
matthewelwell Aug 19, 2024
1feaf72
Merge branch 'refs/heads/main' into feat(versioning)/configure-num-ve…
matthewelwell Aug 19, 2024
c3f3866
Add tests for organisation and project viewsets which are limited on …
matthewelwell Aug 19, 2024
29ce23c
Add tests for starting / ending trial
matthewelwell Aug 19, 2024
995f78b
Merge branch 'refs/heads/main' into feat(versioning)/configure-num-ve…
matthewelwell Aug 20, 2024
1bc7d32
Simplify forms post merge
matthewelwell Aug 20, 2024
f9b4ab4
Ensure that data is correctly written to osic record
matthewelwell Aug 20, 2024
eff8c22
Add grandfather logic into code
matthewelwell Aug 20, 2024
33ce657
Add test and fix logic
matthewelwell Aug 21, 2024
8aee862
Merge branch 'refs/heads/main' into feat(versioning)/configure-num-ve…
matthewelwell Aug 21, 2024
93a95eb
Handle null VERSIONING_RELEASE_DATE
matthewelwell Sep 3, 2024
dc1107b
Fix tests
matthewelwell Sep 3, 2024
90e0f05
Add comment / refactor to facilitate contextual comment
matthewelwell Sep 3, 2024
525d516
Merge branch 'refs/heads/main' into feat(versioning)/configure-num-ve…
matthewelwell Sep 10, 2024
1bac612
Merge branch 'refs/heads/main' into feat(versioning)/configure-num-ve…
matthewelwell Sep 13, 2024
c67b429
Merge branch 'refs/heads/main' into feat(versioning)/configure-num-ve…
matthewelwell Oct 8, 2024
422e5df
Merge branch 'refs/heads/main' into feat(versioning)/configure-num-ve…
matthewelwell Oct 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading