Skip to content

Commit

Permalink
WIP: licensing
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewelwell committed May 16, 2024
1 parent c5aa610 commit c6274ce
Show file tree
Hide file tree
Showing 11 changed files with 145 additions and 5 deletions.
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"organisations",
"organisations.invites",
"organisations.permissions",
"organisations.subscriptions.licensing", # TODO: should this be added conditionally?
"projects",
"sales_dashboard",
"environments",
Expand Down
14 changes: 11 additions & 3 deletions api/organisations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,10 +398,18 @@ def _get_subscription_metadata_for_self_hosted(self) -> BaseSubscriptionMetadata
if not is_enterprise():
return FREE_PLAN_SUBSCRIPTION_METADATA

# TODO: this feels odd returning to the organisation that we likely just came
# from to get to this method.
if hasattr(self.organisation, "licence"):
licence_information = self.organisation.licence.get_licence_information()
return BaseSubscriptionMetadata(
seats=licence_information.num_seats,
projects=licence_information.num_projects,
)

return BaseSubscriptionMetadata(
seats=self.max_seats,
api_calls=self.max_api_calls,
projects=None,
seats=MAX_SEATS_IN_FREE_PLAN,
projects=settings.MAX_PROJECTS_IN_FREE_PLAN,
)

def add_single_seat(self):
Expand Down
1 change: 1 addition & 0 deletions api/organisations/subscriptions/licensing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# TODO: split this into a private package?
19 changes: 19 additions & 0 deletions api/organisations/subscriptions/licensing/licensing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import typing
from datetime import datetime

from pydantic import BaseModel


class LicenceInformation(BaseModel):
organisation_name: str
plan_id: str

department_name: typing.Optional[str] = None
expiry_date: typing.Optional[datetime] = None

# TODO: should these live in a nested object?
num_seats: int
num_projects: int # TODO: what about Flagsmith on Flagsmith project?
num_api_calls: typing.Optional[
int
] = None # required to support private cloud installs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 3.2.24 on 2024-03-15 15:52

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

dependencies = [
('organisations', '0052_create_hubspot_organisation'),
]

operations = [
migrations.CreateModel(
name='OrganisationLicence',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('content', models.TextField(blank=True)),
('organisation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='organisations.organisation')),
],
),
]
Empty file.
18 changes: 18 additions & 0 deletions api/organisations/subscriptions/licensing/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.db import models

from organisations.subscriptions.licensing.licensing import LicenceInformation


class OrganisationLicence(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

organisation = models.OneToOneField(
"organisations.Organisation", related_name="licence", on_delete=models.CASCADE
)

content = models.TextField(blank=True)

def get_licence_information(self) -> LicenceInformation:
# TODO: decryption
return LicenceInformation.parse_raw(self.content)
20 changes: 20 additions & 0 deletions api/organisations/subscriptions/licensing/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from rest_framework import serializers
from rest_framework.decorators import api_view
from rest_framework.request import Request
from rest_framework.response import Response

from organisations.subscriptions.licensing.models import OrganisationLicence


@api_view(http_method_names=["PUT"])
def create_or_update_licence(
request: Request, organisation_id: int, **kwargs
) -> Response:
if "licence" not in request.FILES:
raise serializers.ValidationError("No licence file provided.")

OrganisationLicence.objects.update_or_create(
organisation_id=organisation_id,
defaults={"content": request.FILES["licence"].read().decode("utf-8")},
)
return Response(200)
4 changes: 2 additions & 2 deletions api/organisations/subscriptions/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ class BaseSubscriptionMetadata:
def __init__(
self,
seats: int = 0,
api_calls: int = 0,
api_calls: typing.Optional[int] = None,
projects: typing.Optional[int] = None,
chargebee_email=None,
chargebee_email: str = None,
):
self.seats = seats
self.api_calls = api_calls
Expand Down
9 changes: 9 additions & 0 deletions api/organisations/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
UserOrganisationPermissionViewSet,
UserPermissionGroupOrganisationPermissionViewSet,
)
from .subscriptions.licensing.views import create_or_update_licence

router = routers.DefaultRouter()
router.register(r"", views.OrganisationViewSet, basename="organisation")
Expand Down Expand Up @@ -140,6 +141,14 @@
OrganisationAPIUsageNotificationView.as_view(),
name="organisation-api-usage-notification",
),
# TODO:
# - is this the best url?
# - do we need to conditionally add this URL, or just raise exception if not valid in the view?
path(
"<int:organisation_id>/licence",
create_or_update_licence,
name="create-or-update-licence",
),
]

if settings.IS_RBAC_INSTALLED:
Expand Down
38 changes: 38 additions & 0 deletions api/tests/unit/organisations/test_unit_organisations_views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import importlib
import json
from datetime import datetime, timedelta
from typing import Type
Expand All @@ -9,6 +10,7 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core import mail
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.models import Model
from django.urls import reverse
from django.utils import timezone
Expand All @@ -20,6 +22,7 @@
from rest_framework import status
from rest_framework.test import APIClient, override_settings

import organisations.urls
from environments.models import Environment
from environments.permissions.models import UserEnvironmentPermission
from features.models import Feature
Expand All @@ -42,6 +45,7 @@
SUBSCRIPTION_BILLING_STATUS_ACTIVE,
SUBSCRIPTION_BILLING_STATUS_DUNNING,
)
from organisations.subscriptions.licensing.models import OrganisationLicence
from projects.models import Project, UserProjectPermission
from segments.models import Segment
from users.models import (
Expand Down Expand Up @@ -1873,3 +1877,37 @@ def test_validation_error_if_non_numeric_organisation_id(

# Then
assert response.status_code == status.HTTP_400_BAD_REQUEST


def test_create_or_update_licence(
organisation: Organisation, admin_client: APIClient, mocker: MockerFixture
) -> None:
# Given
mocker.patch("organisations.urls.is_enterprise", return_value=True)

importlib.reload(organisations.urls)

url = reverse(
"api-v1:organisations:create-or-update-licence", args=[organisation.id]
)

licence_data = {
"organisation_name": "Test Organisation",
"plan": "Enterprise",
"num_seats": 20,
"num_projects": 3,
}

licence = SimpleUploadedFile(
name="licence.txt",
content=json.dumps(licence_data).encode(),
content_type="text/plain",
)

# When
response = admin_client.put(url, data={"licence": licence})

# Then
assert response.status_code == status.HTTP_200_OK

assert OrganisationLicence.objects.filter(organisation=organisation).exists()

0 comments on commit c6274ce

Please sign in to comment.