1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-03-28 18:15:09 +00:00

Resolve "Soft Limit for Enterprise Seats"

This commit is contained in:
Nigel Gott 2022-10-31 15:00:41 +00:00 committed by Bram Wiepjes
parent 139d9865f2
commit ea38da6402
26 changed files with 760 additions and 225 deletions
backend/tests/baserow/contrib/database/view
docker-compose.dev.yml
enterprise
backend
src/baserow_enterprise
tests/baserow_enterprise_tests
web-frontend/modules/baserow_enterprise
premium
backend
web-frontend/modules/baserow_premium
web-frontend/modules/core
assets/scss/components
components

View file

@ -1457,7 +1457,7 @@ def test_last_modified_datetime_equals_days_ago_filter_type(data_fixture):
row_2 = model.objects.create(**{})
# one day before the filter
with freeze_time(when - timedelta(hours=(when.hour + 1))):
with freeze_time(when - timedelta(hours=(when.hour + 2))):
row_3 = model.objects.create(**{})
with freeze_time(when.strftime("%Y-%m-%d")):

View file

@ -42,6 +42,8 @@ services:
# Open stdin and tty so when attaching key input works as expected.
stdin_open: true
tty: true
extra_hosts:
- "host.docker.internal:host-gateway"
web-frontend:
image: baserow_web-frontend_dev:latest
@ -106,6 +108,8 @@ services:
# Open stdin and tty so when attaching key input works as expected.
stdin_open: true
tty: true
extra_hosts:
- "host.docker.internal:host-gateway"
celery-beat-worker:
image: baserow_backend_dev:latest
@ -126,6 +130,8 @@ services:
# Open stdin and tty so when attaching key input works as expected.
stdin_open: true
tty: true
extra_hosts:
- "host.docker.internal:host-gateway"
mjml-email-compiler:
build:

View file

@ -1,11 +1,41 @@
from django.contrib.auth import get_user_model
from django.db.models import Q
from baserow_premium.license.features import PREMIUM
from baserow_premium.license.models import License
from baserow_premium.license.registries import LicenseType
from baserow.core.models import GroupUser
from baserow_enterprise.features import RBAC, SSO, TEAMS
User = get_user_model()
class EnterpriseLicenseType(LicenseType):
type = "enterprise"
order = 100
features = [PREMIUM, RBAC, SSO, TEAMS]
instance_wide = True
seats_manually_assigned = False
def get_seats_taken(self, license_object_of_this_type: License) -> int:
return (
GroupUser.objects.filter(
~Q(permissions="VIEWER"),
user__profile__to_be_deleted=False,
user__is_active=True,
)
.values("user_id")
.distinct()
.count()
)
def get_free_users_count(self, license_object_of_this_type: License) -> int:
total_users = User.objects.filter(
profile__to_be_deleted=False, is_active=True
).count()
return total_users - self.get_seats_taken(license_object_of_this_type)
def handle_seat_overflow(self, seats_taken: int, license_object: License):
# We don't have to do anything because the seat limit is a soft limit.
pass

View file

@ -2,14 +2,20 @@ from django.contrib.auth.models import AnonymousUser
from django.test.utils import override_settings
import pytest
import responses
from baserow_premium.api.user.user_data_types import ActiveLicensesDataType
from baserow_premium.license.exceptions import CantManuallyChangeSeatsError
from baserow_premium.license.features import PREMIUM
from baserow_premium.license.handler import LicenseHandler
from freezegun import freeze_time
from responses import json_params_matcher
from baserow.api.user.registries import user_data_registry
from baserow.core.models import Settings
from baserow_enterprise.features import RBAC, SSO
from baserow_enterprise.license_types import EnterpriseLicenseType
from baserow_enterprise.role.handler import RoleAssignmentHandler
from baserow_enterprise.role.models import Role, RoleAssignment
VALID_ONE_SEAT_ENTERPRISE_LICENSE = (
# id: "1", instance_id: "1"
@ -46,23 +52,6 @@ def test_enterprise_users_not_assigned_a_seat_still_get_enterprise_features(
assert LicenseHandler.user_has_feature_instance_wide(RBAC, user)
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_enterprise_users_assigned_a_seat_still_get_enterprise_features(
data_fixture,
):
Settings.objects.update_or_create(defaults={"instance_id": "1"})
user = data_fixture.create_user(is_staff=True)
license_object = LicenseHandler.register_license(
user, VALID_ONE_SEAT_ENTERPRISE_LICENSE
)
LicenseHandler.add_user_to_license(user, license_object, user)
assert LicenseHandler.user_has_feature_instance_wide(PREMIUM, user)
assert LicenseHandler.user_has_feature_instance_wide(SSO, user)
assert LicenseHandler.user_has_feature_instance_wide(RBAC, user)
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_enterprise_features_will_not_be_active_instance_wide_when_enterprise_not_active(
@ -98,7 +87,7 @@ def test_enterprise_users_not_assigned_a_seat_dont_get_features_if_license_not_a
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_enterprise_users_assigned_a_seat_still_dont_get_features_if_license_inactive(
def test_enterprise_users_dont_get_features_if_license_inactive(
data_fixture,
):
Settings.objects.update_or_create(defaults={"instance_id": "1"})
@ -106,7 +95,6 @@ def test_enterprise_users_assigned_a_seat_still_dont_get_features_if_license_ina
license_object = LicenseHandler.register_license(
user, VALID_ONE_SEAT_ENTERPRISE_LICENSE
)
LicenseHandler.add_user_to_license(user, license_object, user)
# Before the license is active
with freeze_time("2020-02-01 01:23"):
@ -200,7 +188,6 @@ def test_license_user_user_data_has_no_enterprise_license_when_not_active(
license_obj = LicenseHandler.register_license(
user, VALID_ONE_SEAT_ENTERPRISE_LICENSE
)
LicenseHandler.add_user_to_license(user, license_obj, user)
al = user_data_registry.get_by_type(ActiveLicensesDataType)
# Before the license is active
@ -213,7 +200,7 @@ def test_license_user_user_data_has_no_enterprise_license_when_not_active(
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_assigned_user_data_enables_enterprise_features_instance_wide(
def test_enabling_enterprise_user_data_gets_enterprise_features_instance_wide(
data_fixture,
):
Settings.objects.update_or_create(defaults={"instance_id": "1"})
@ -221,7 +208,6 @@ def test_assigned_user_data_enables_enterprise_features_instance_wide(
license_obj = LicenseHandler.register_license(
user, VALID_ONE_SEAT_ENTERPRISE_LICENSE
)
LicenseHandler.add_user_to_license(user, license_obj, user)
al = user_data_registry.get_by_type(ActiveLicensesDataType)
# Before the license is active
@ -233,15 +219,12 @@ def test_assigned_user_data_enables_enterprise_features_instance_wide(
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_assigned_user_data_no_enterprise_features_instance_wide_not_active(
def test_user_data_no_enterprise_features_instance_wide_not_active(
data_fixture,
):
Settings.objects.update_or_create(defaults={"instance_id": "1"})
user = data_fixture.create_user(is_staff=True)
license_obj = LicenseHandler.register_license(
user, VALID_ONE_SEAT_ENTERPRISE_LICENSE
)
LicenseHandler.add_user_to_license(user, license_obj, user)
LicenseHandler.register_license(user, VALID_ONE_SEAT_ENTERPRISE_LICENSE)
al = user_data_registry.get_by_type(ActiveLicensesDataType)
# Before the license is active
@ -250,3 +233,212 @@ def test_assigned_user_data_no_enterprise_features_instance_wide_not_active(
"instance_wide": {},
"per_group": {},
}
@pytest.mark.django_db
@override_settings(DEBUG=True)
@responses.activate
def test_check_licenses_with_enterprise_license_sends_seat_data(
enterprise_data_fixture, synced_roles
):
license_object = enterprise_data_fixture.enable_enterprise()
with freeze_time("2021-07-01 12:00"):
responses.add(
responses.POST,
"http://host.docker.internal:8001/api/saas/licenses/check/",
json={
VALID_ONE_SEAT_ENTERPRISE_LICENSE.decode(): {
"type": "ok",
"detail": "",
},
},
match=[
json_params_matcher(
{
"licenses": [VALID_ONE_SEAT_ENTERPRISE_LICENSE.decode()],
"instance_id": Settings.objects.get().instance_id,
"extra_license_info": [
{
"id": license_object.id,
"free_users_count": 0,
"seats_taken": 1,
}
],
}
)
],
status=200,
)
LicenseHandler.check_licenses([license_object])
responses.assert_call_count(
"http://host.docker.internal:8001/api/saas/licenses/check/", 1
)
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_enterprise_license_counts_viewers_as_free(
enterprise_data_fixture, data_fixture, synced_roles
):
license_object = enterprise_data_fixture.enable_enterprise()
user = data_fixture.create_user()
user2 = data_fixture.create_user()
user3 = data_fixture.create_user()
group = data_fixture.create_group(user=user, members=[user2, user3])
table = data_fixture.create_database_table(user=user)
admin_role = Role.objects.get(uid="ADMIN")
viewer_role = Role.objects.get(uid="VIEWER")
role_assignment_handler = RoleAssignmentHandler()
assert len(RoleAssignment.objects.all()) == 0
role_assignment_handler.assign_role(user, group, admin_role)
role_assignment_handler.assign_role(user2, group, viewer_role)
role_assignment_handler.assign_role(user3, group, viewer_role)
assert EnterpriseLicenseType().get_free_users_count(license_object) == 2
assert EnterpriseLicenseType().get_seats_taken(license_object) == 1
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_user_who_is_commenter_in_one_group_and_viewer_in_another_is_not_free(
enterprise_data_fixture, data_fixture, synced_roles
):
license_object = enterprise_data_fixture.enable_enterprise()
user = data_fixture.create_user()
user2 = data_fixture.create_user()
group1 = data_fixture.create_group(user=user, members=[user2])
group2 = data_fixture.create_group(user=user, members=[user2])
admin_role = Role.objects.get(uid="ADMIN")
commenter_role = Role.objects.get(uid="COMMENTER")
viewer_role = Role.objects.get(uid="VIEWER")
role_assignment_handler = RoleAssignmentHandler()
role_assignment_handler.assign_role(user, group1, admin_role)
role_assignment_handler.assign_role(user2, group1, viewer_role)
role_assignment_handler.assign_role(user, group2, admin_role)
role_assignment_handler.assign_role(user2, group2, commenter_role)
assert len(RoleAssignment.objects.all()) == 0
assert EnterpriseLicenseType().get_free_users_count(license_object) == 0
assert EnterpriseLicenseType().get_seats_taken(license_object) == 2
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_user_marked_for_deletion_is_not_counted_as_a_paid_user(
enterprise_data_fixture, data_fixture, synced_roles
):
license_object = enterprise_data_fixture.enable_enterprise()
user = data_fixture.create_user()
user2 = data_fixture.create_user()
group1 = data_fixture.create_group(user=user, members=[user2])
group2 = data_fixture.create_group(user=user, members=[user2])
admin_role = Role.objects.get(uid="ADMIN")
commenter_role = Role.objects.get(uid="COMMENTER")
viewer_role = Role.objects.get(uid="VIEWER")
role_assignment_handler = RoleAssignmentHandler()
role_assignment_handler.assign_role(user, group1, admin_role)
role_assignment_handler.assign_role(user2, group1, viewer_role)
role_assignment_handler.assign_role(user, group2, admin_role)
role_assignment_handler.assign_role(user2, group2, commenter_role)
assert len(RoleAssignment.objects.all()) == 0
user2.profile.to_be_deleted = True
user2.profile.save()
assert EnterpriseLicenseType().get_free_users_count(license_object) == 0
assert EnterpriseLicenseType().get_seats_taken(license_object) == 1
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_user_deactivated_user_is_not_counted_as_a_paid_user(
enterprise_data_fixture, data_fixture, synced_roles
):
license_object = enterprise_data_fixture.enable_enterprise()
user = data_fixture.create_user()
user2 = data_fixture.create_user(is_active=False)
group1 = data_fixture.create_group(user=user, members=[user2])
group2 = data_fixture.create_group(user=user, members=[user2])
admin_role = Role.objects.get(uid="ADMIN")
commenter_role = Role.objects.get(uid="COMMENTER")
builder_role = Role.objects.get(uid="BUILDER")
role_assignment_handler = RoleAssignmentHandler()
role_assignment_handler.assign_role(user, group1, admin_role)
role_assignment_handler.assign_role(user2, group1, builder_role)
role_assignment_handler.assign_role(user, group2, admin_role)
role_assignment_handler.assign_role(user2, group2, commenter_role)
assert len(RoleAssignment.objects.all()) == 0
assert EnterpriseLicenseType().get_free_users_count(license_object) == 0
assert EnterpriseLicenseType().get_seats_taken(license_object) == 1
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_cant_manually_add_seats_to_enterprise_version(
enterprise_data_fixture, data_fixture, synced_roles
):
license_object = enterprise_data_fixture.enable_enterprise()
user = data_fixture.create_user(is_staff=True)
user2 = data_fixture.create_user()
data_fixture.create_group(user=user, members=[user2])
with pytest.raises(CantManuallyChangeSeatsError):
LicenseHandler.add_user_to_license(user, license_object, user2)
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_cant_manually_remove_seats_from_enterprise_version(
enterprise_data_fixture, data_fixture, synced_roles
):
license_object = enterprise_data_fixture.enable_enterprise()
user = data_fixture.create_user(is_staff=True)
user2 = data_fixture.create_user()
data_fixture.create_group(user=user, members=[user2])
with pytest.raises(CantManuallyChangeSeatsError):
LicenseHandler.remove_user_from_license(user, license_object, user2)
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_cant_manually_add_all_users_to_seats_in_enterprise_version(
enterprise_data_fixture, data_fixture, synced_roles
):
license_object = enterprise_data_fixture.enable_enterprise()
user = data_fixture.create_user(is_staff=True)
user2 = data_fixture.create_user()
data_fixture.create_group(user=user, members=[user2])
with pytest.raises(CantManuallyChangeSeatsError):
LicenseHandler.fill_remaining_seats_of_license(user, license_object)
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_cant_manually_remove_all_users_from_seats_in_enterprise_version(
enterprise_data_fixture, data_fixture, synced_roles
):
license_object = enterprise_data_fixture.enable_enterprise()
user = data_fixture.create_user(is_staff=True)
user2 = data_fixture.create_user()
data_fixture.create_group(user=user, members=[user2])
with pytest.raises(CantManuallyChangeSeatsError):
LicenseHandler.remove_all_users_from_license(user, license_object)

View file

@ -18,10 +18,12 @@ class EnterpriseFixtures:
def enable_enterprise(self):
Settings.objects.update_or_create(defaults={"instance_id": "1"})
if not License.objects.filter(cached_untrusted_instance_wide=True).exists():
License.objects.create(
return License.objects.create(
license=VALID_ONE_SEAT_ENTERPRISE_LICENSE.decode(),
cached_untrusted_instance_wide=True,
)
else:
return License.objects.filter(cached_untrusted_instance_wide=True).get()
def create_team(self, **kwargs):
if "name" not in kwargs:

View file

@ -641,7 +641,7 @@ def test_get_permissions_object(data_fixture, enterprise_data_fixture, synced_ro
@override_settings(
PERMISSION_MANAGERS=["core", "staff", "member", "role", "basic"],
)
def test_filter_queryset(data_fixture, enterprise_data_fixture, synced_roles):
def test_filter_queryset(data_fixture, enterprise_data_fixture):
(
admin,
builder,

View file

@ -41,4 +41,18 @@ export class EnterpriseLicenseType extends LicenseType {
getOrder() {
return 100
}
getSeatsManuallyAssigned() {
return false
}
getLicenseDescription(license) {
const { i18n } = this.app
return i18n.t('enterprise.licenseDescription')
}
getLicenseSeatOverflowWarning(license) {
const { i18n } = this.app
return i18n.t('enterprise.overflowWarning')
}
}

View file

@ -5,7 +5,9 @@
"enterpriseFeatures": "Premium and Enterprise Features",
"rbac": "RBAC",
"sso": "SSO",
"deactivated": "Deactivated"
"deactivated": "Deactivated",
"licenseDescription": "Viewers are free with Baserow Enterprise. If a user has any other role, in any group then they will use a paid seat automatically.",
"overflowWarning": "You have too many non-viewer users and have used up all of your paid seats. Change users to become viewers on each groups members page."
},
"adminType": {
"Authentication": "Authentication"

View file

@ -42,3 +42,9 @@ ERROR_NO_SEATS_LEFT_IN_LICENSE = (
HTTP_400_BAD_REQUEST,
"Can't add the user because there are not seats left in the license.",
)
ERROR_CANT_MANUALLY_CHANGE_SEATS = (
"ERROR_CANT_MANUALLY_CHANGE_SEATS",
HTTP_400_BAD_REQUEST,
"Can't manually change seats for a license of this type as they are automatically "
"allocated.",
)

View file

@ -19,6 +19,9 @@ class LicenseSerializer(serializers.ModelSerializer):
valid_through = serializers.DateTimeField(
help_text="Until which timestamp the license is active."
)
free_users_count = serializers.SerializerMethodField(
help_text="The amount of free users that are currently using the license."
)
seats_taken = serializers.SerializerMethodField(
help_text="The amount of users that are currently using the license."
)
@ -49,6 +52,7 @@ class LicenseSerializer(serializers.ModelSerializer):
"last_check",
"valid_from",
"valid_through",
"free_users_count",
"seats_taken",
"seats",
"product_code",
@ -59,9 +63,11 @@ class LicenseSerializer(serializers.ModelSerializer):
@extend_schema_field(OpenApiTypes.INT)
def get_seats_taken(self, obj):
return (
obj.seats_taken if hasattr(obj, "seats_taken") else obj.users.all().count()
)
return obj.license_type.get_seats_taken(obj)
@extend_schema_field(OpenApiTypes.INT)
def get_free_users_count(self, obj):
return obj.license_type.get_free_users_count(obj)
class RegisterLicenseSerializer(serializers.Serializer):

View file

@ -4,6 +4,7 @@ from django.db import transaction
from django.db.models import Count, Q
from baserow_premium.license.exceptions import (
CantManuallyChangeSeatsError,
InvalidLicenseError,
LicenseAlreadyExistsError,
LicenseHasExpiredError,
@ -28,6 +29,7 @@ from baserow.api.user.errors import ERROR_USER_NOT_FOUND
from baserow.core.db import LockedAtomicTransaction
from .errors import (
ERROR_CANT_MANUALLY_CHANGE_SEATS,
ERROR_INVALID_LICENSE,
ERROR_LICENSE_ALREADY_EXISTS,
ERROR_LICENSE_DOES_NOT_EXIST,
@ -143,8 +145,9 @@ class AdminLicenseView(APIView):
)
@map_exceptions({License.DoesNotExist: ERROR_LICENSE_DOES_NOT_EXIST})
def get(self, request, id):
license = License.objects.prefetch_related("users__user").get(pk=id)
return Response(LicenseWithUsersSerializer(license).data)
license_object = License.objects.prefetch_related("users__user").get(pk=id)
license_data = LicenseWithUsersSerializer(license_object).data
return Response(license_data)
@extend_schema(
parameters=[
@ -209,6 +212,7 @@ class AdminLicenseUserView(APIView):
[
"ERROR_USER_ALREADY_ON_LICENSE",
"ERROR_NO_SEATS_LEFT_IN_LICENSE",
"ERROR_CANT_MANUALLY_CHANGE_SEATS",
]
),
404: get_error_schema(
@ -222,6 +226,7 @@ class AdminLicenseUserView(APIView):
User.DoesNotExist: ERROR_USER_NOT_FOUND,
UserAlreadyOnLicenseError: ERROR_USER_ALREADY_ON_LICENSE,
NoSeatsLeftInLicenseError: ERROR_NO_SEATS_LEFT_IN_LICENSE,
CantManuallyChangeSeatsError: ERROR_CANT_MANUALLY_CHANGE_SEATS,
}
)
@transaction.atomic
@ -257,8 +262,12 @@ class AdminLicenseUserView(APIView):
request=None,
responses={
204: None,
400: get_error_schema(["ERROR_CANT_MANUALLY_CHANGE_SEATS"]),
404: get_error_schema(
["ERROR_LICENSE_DOES_NOT_EXIST", "ERROR_USER_NOT_FOUND"]
[
"ERROR_LICENSE_DOES_NOT_EXIST",
"ERROR_USER_NOT_FOUND",
]
),
},
)
@ -266,6 +275,7 @@ class AdminLicenseUserView(APIView):
{
License.DoesNotExist: ERROR_LICENSE_DOES_NOT_EXIST,
User.DoesNotExist: ERROR_USER_NOT_FOUND,
CantManuallyChangeSeatsError: ERROR_CANT_MANUALLY_CHANGE_SEATS,
}
)
@transaction.atomic
@ -298,10 +308,16 @@ class AdminLicenseFillSeatsView(APIView):
request=None,
responses={
200: LicenseUserSerializer(many=True),
400: get_error_schema(["ERROR_CANT_MANUALLY_CHANGE_SEATS"]),
404: get_error_schema(["ERROR_LICENSE_DOES_NOT_EXIST"]),
},
)
@map_exceptions({License.DoesNotExist: ERROR_LICENSE_DOES_NOT_EXIST})
@map_exceptions(
{
License.DoesNotExist: ERROR_LICENSE_DOES_NOT_EXIST,
CantManuallyChangeSeatsError: ERROR_CANT_MANUALLY_CHANGE_SEATS,
}
)
@transaction.atomic
def post(self, request, id):
license = License.objects.get(pk=id)
@ -334,10 +350,16 @@ class AdminRemoveAllUsersFromLicenseView(APIView):
request=None,
responses={
204: None,
400: get_error_schema(["ERROR_CANT_MANUALLY_CHANGE_SEATS"]),
404: get_error_schema(["ERROR_LICENSE_DOES_NOT_EXIST"]),
},
)
@map_exceptions({License.DoesNotExist: ERROR_LICENSE_DOES_NOT_EXIST})
@map_exceptions(
{
License.DoesNotExist: ERROR_LICENSE_DOES_NOT_EXIST,
CantManuallyChangeSeatsError: ERROR_CANT_MANUALLY_CHANGE_SEATS,
}
)
@transaction.atomic
def post(self, request, id):
license = License.objects.get(pk=id)

View file

@ -56,5 +56,9 @@ class NoSeatsLeftInLicenseError(Exception):
"""Raised when there are no seats left in the license."""
class CantManuallyChangeSeatsError(Exception):
"""Raised if trying to assign/remove users from seats for a given license type"""
class LicenseAuthorityUnavailable(Exception):
"""Raised when the license authority can't be reached."""

View file

@ -4,18 +4,21 @@ import hashlib
import json
import logging
from os.path import dirname, join
from typing import List, Union
from typing import Any, Dict, List, Optional, Union
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser, Group
from django.db import transaction
from django.db import DatabaseError, transaction
from django.db.models import Q
from django.utils.timezone import make_aware, now, utc
import requests
from baserow_premium.api.user.user_data_types import ActiveLicensesDataType
from baserow_premium.license.exceptions import InvalidLicenseError
from baserow_premium.license.exceptions import (
CantManuallyChangeSeatsError,
InvalidLicenseError,
)
from baserow_premium.license.models import License
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.backends import default_backend
@ -236,9 +239,38 @@ class LicenseHandler:
return payload
@classmethod
def send_license_info_and_fetch_license_status_with_authority(
cls, license_objects: List[License]
):
license_payloads = []
extra_license_info = []
for license_object in license_objects:
license_payloads.append(license_object.license)
try:
license_type = license_object.license_type
extra_info = {
"id": license_object.license_id,
"seats_taken": license_type.get_seats_taken(license_object),
"free_users_count": license_type.get_free_users_count(
license_object
),
}
extra_license_info.append(extra_info)
except (InvalidLicenseError, UnsupportedLicenseError, DatabaseError):
pass
return cls.fetch_license_status_with_authority(
license_payloads, extra_license_info
)
@classmethod
def fetch_license_status_with_authority(
cls, license_payloads: List[Union[str, bytes]]
cls,
license_payloads: List[Union[str, bytes]],
extra_license_info: Optional[List[Dict[str, Any]]] = None,
):
"""
Fetches the state of the license with the authority. It could be that the
@ -247,27 +279,32 @@ class LicenseHandler:
:param license_payloads: A list of licenses that must be checked with the
authority.
:param extra_license_info: A list of extra information about each license
to send to the authority.
:return: The state of each license provided.
"""
license_payloads = [
payload if isinstance(payload, str) else payload.decode()
for payload in license_payloads
]
settings_object = CoreHandler().get_settings()
try:
base_url = (
"http://172.17.0.1:8001" if settings.DEBUG else "https://api.baserow.io"
)
base_url = "https://api.baserow.io"
headers = {}
if settings.DEBUG:
base_url = "http://host.docker.internal:8001"
headers["Host"] = "localhost"
authority_url = f"{base_url}/api/saas/licenses/check/"
response = requests.post(
f"{base_url}/api/saas/licenses/check/",
authority_url,
json={
"licenses": license_payloads,
"instance_id": settings_object.instance_id,
"extra_license_info": extra_license_info,
},
timeout=10,
headers=headers,
)
if response.status_code == HTTP_200_OK:
@ -302,13 +339,11 @@ class LicenseHandler:
:return: The updated license objects.
"""
licenses_to_check = [
license_object.license for license_object in license_objects
]
try:
authority_response = cls.fetch_license_status_with_authority(
licenses_to_check
authority_response = (
cls.send_license_info_and_fetch_license_status_with_authority(
license_objects
)
)
for license_object in license_objects:
@ -345,15 +380,11 @@ class LicenseHandler:
license_object.delete()
continue
seats_taken = license_object.users.all().count()
seats_taken = license_object.license_type.get_seats_taken(license_object)
if seats_taken > license_object.seats:
# If there are more seats taken than the license allows, we need to
# remove the active seats that are outside of the limit.
LicenseUser.objects.filter(
pk__in=license_object.users.all()
.order_by("pk")
.values_list("pk")[license_object.seats : seats_taken]
).delete()
license_object.license_type.handle_seat_overflow(
seats_taken, license_object
)
license_object.last_check = now()
license_object.save()
@ -513,6 +544,9 @@ class LicenseHandler:
"The user already has a seat on this license."
)
if not license_object.license_type.seats_manually_assigned:
raise CantManuallyChangeSeatsError()
seats_taken = license_object.users.all().count()
if seats_taken >= license_object.seats:
raise NoSeatsLeftInLicenseError(
@ -552,6 +586,9 @@ class LicenseHandler:
if not requesting_user.is_staff:
raise IsNotAdminError()
if not license_object.license_type.seats_manually_assigned:
raise CantManuallyChangeSeatsError()
LicenseUser.objects.filter(license=license_object, user=user).delete()
al = user_data_registry.get_by_type(ActiveLicensesDataType)
@ -584,6 +621,9 @@ class LicenseHandler:
if not requesting_user.is_staff:
raise IsNotAdminError()
if not license_object.license_type.seats_manually_assigned:
raise CantManuallyChangeSeatsError()
already_in_license = license_object.users.all().values_list(
"user_id", flat=True
)
@ -631,6 +671,9 @@ class LicenseHandler:
if not requesting_user.is_staff:
raise IsNotAdminError()
if not license_object.license_type.seats_manually_assigned:
raise CantManuallyChangeSeatsError()
license_users = LicenseUser.objects.filter(license=license_object)
license_user_ids = list(license_users.values_list("user_id", flat=True))
license_users.delete()

View file

@ -1,4 +1,5 @@
from baserow_premium.license.features import PREMIUM
from baserow_premium.license.models import License, LicenseUser
from baserow_premium.license.registries import LicenseType
@ -6,3 +7,20 @@ class PremiumLicenseType(LicenseType):
type = "premium"
order = 10
features = [PREMIUM]
def get_seats_taken(self, obj: License) -> int:
return (
obj.seats_taken if hasattr(obj, "seats_taken") else obj.users.all().count()
)
def get_free_users_count(self, license_object_of_this_type: License) -> int:
return 0
def handle_seat_overflow(self, seats_taken: int, license_object: License):
# If there are more seats taken than the license allows, we need to
# remove the active seats that are outside of the limit.
LicenseUser.objects.filter(
pk__in=license_object.users.all()
.order_by("pk")
.values_list("pk")[license_object.seats : seats_taken]
).delete()

View file

@ -1,6 +1,8 @@
import abc
from typing import List
from baserow_premium.license.models import License
from baserow.core.registry import Instance, Registry
@ -27,9 +29,20 @@ class LicenseType(abc.ABC, Instance):
regardless of if they are added to a seat on the license or not.
"""
seats_manually_assigned: bool = True
def has_feature(self, feature: str):
return feature in self.features
def get_seats_taken(self, license_object_of_this_type: License) -> int:
raise NotImplementedError()
def get_free_users_count(self, license_object_of_this_type: License) -> int:
raise NotImplementedError()
def handle_seat_overflow(self, seats_taken: int, license_object: License):
raise NotImplementedError()
class LicenseTypeRegistry(Registry[LicenseType]):
name = "license_type"

View file

@ -732,7 +732,7 @@ def test_admin_check_license(api_client, data_fixture):
with freeze_time("2021-07-01 12:00"):
responses.add(
responses.POST,
"http://172.17.0.1:8001/api/saas/licenses/check/",
"http://host.docker.internal:8001/api/saas/licenses/check/",
json={
VALID_ONE_SEAT_LICENSE.decode(): {
"type": "invalid",

View file

@ -281,7 +281,7 @@ def test_fetch_license_status_with_authority_unavailable(data_fixture):
responses.add(
responses.POST,
"http://172.17.0.1:8001/api/saas/licenses/check/",
"http://host.docker.internal:8001/api/saas/licenses/check/",
json={"error": "error"},
status=400,
)
@ -291,7 +291,7 @@ def test_fetch_license_status_with_authority_unavailable(data_fixture):
responses.add(
responses.POST,
"http://172.17.0.1:8001/api/saas/licenses/check/",
"http://host.docker.internal:8001/api/saas/licenses/check/",
body="not_json",
status=200,
)
@ -308,7 +308,7 @@ def test_fetch_license_status_with_authority_invalid_response(data_fixture):
responses.add(
responses.POST,
"http://172.17.0.1:8001/api/saas/licenses/check/",
"http://host.docker.internal:8001/api/saas/licenses/check/",
body="not_json",
status=200,
)
@ -342,7 +342,7 @@ def test_fetch_license_status_with_authority(data_fixture):
responses.add(
responses.POST,
"http://172.17.0.1:8001/api/saas/licenses/check/",
"http://host.docker.internal:8001/api/saas/licenses/check/",
json={"test": {"type": "ok", "detail": ""}},
status=200,
)
@ -372,7 +372,7 @@ def test_check_licenses_with_authority_check(premium_data_fixture):
with freeze_time("2021-07-01 12:00"):
responses.add(
responses.POST,
"http://172.17.0.1:8001/api/saas/licenses/check/",
"http://host.docker.internal:8001/api/saas/licenses/check/",
json={
"invalid": {"type": "invalid", "detail": ""},
"does_not_exist": {"type": "does_not_exist", "detail": ""},
@ -529,7 +529,7 @@ def test_register_license_with_authority_check_ok(data_fixture):
with freeze_time("2021-07-01 12:00"):
responses.add(
responses.POST,
"http://172.17.0.1:8001/api/saas/licenses/check/",
"http://host.docker.internal:8001/api/saas/licenses/check/",
json={VALID_ONE_SEAT_LICENSE.decode(): {"type": "ok", "detail": ""}},
status=200,
)
@ -548,7 +548,7 @@ def test_register_license_with_authority_check_updated(data_fixture):
with freeze_time("2021-07-01 12:00"):
responses.add(
responses.POST,
"http://172.17.0.1:8001/api/saas/licenses/check/",
"http://host.docker.internal:8001/api/saas/licenses/check/",
json={
VALID_ONE_SEAT_LICENSE.decode(): {
"type": "update",
@ -574,7 +574,7 @@ def test_register_license_with_authority_check_does_not_exist(data_fixture):
with freeze_time("2021-07-01 12:00"):
responses.add(
responses.POST,
"http://172.17.0.1:8001/api/saas/licenses/check/",
"http://host.docker.internal:8001/api/saas/licenses/check/",
json={
VALID_ONE_SEAT_LICENSE.decode(): {
"type": "does_not_exist",
@ -598,7 +598,7 @@ def test_register_license_with_authority_check_instance_id_mismatch(data_fixture
with freeze_time("2021-07-01 12:00"):
responses.add(
responses.POST,
"http://172.17.0.1:8001/api/saas/licenses/check/",
"http://host.docker.internal:8001/api/saas/licenses/check/",
json={
VALID_ONE_SEAT_LICENSE.decode(): {
"type": "instance_id_mismatch",
@ -622,7 +622,7 @@ def test_register_license_with_authority_check_invalid(data_fixture):
with freeze_time("2021-07-01 12:00"):
responses.add(
responses.POST,
"http://172.17.0.1:8001/api/saas/licenses/check/",
"http://host.docker.internal:8001/api/saas/licenses/check/",
json={
VALID_ONE_SEAT_LICENSE.decode(): {
"type": "invalid",

View file

@ -39,7 +39,7 @@
flex-basis: 60%;
}
.license-body__body-right {
.license-detail__body-right {
flex-basis: 40%;
@media screen and (min-width: 800px) {
@ -136,3 +136,14 @@
margin-top: 20px;
height: 20px; // placeholder height for the loading animation when visible.
}
.license-detail__available-seats {
width: 100%;
max-width: 400px;
}
.license-detail__available-seats-status {
color: $color-neutral-400;
padding-top: 5px;
font-size: 12px;
}

View file

@ -0,0 +1,69 @@
<template>
<div class="license-detail__available-seats">
<p>
{{ licenseType.getLicenseDescription(license) }}
</p>
<ProgressBar
:show-overflow="true"
:value="paidSeatsUsedPercentage"
:show-value="false"
>
</ProgressBar>
<div class="license-detail__available-seats-status">
{{ paidSeatsStatus }}
</div>
<div
v-if="overSoftLimit"
class="delete-section margin-bottom-0 margin-top-4"
>
<div class="delete-section__label">
<div class="delete-section__label-icon">
<i class="fas fa-exclamation"></i>
</div>
{{ $t('license.moreSeatsNeededTitle') }}
</div>
<p class="delete-section__description">
{{ licenseType.getLicenseSeatOverflowWarning(license) }}
</p>
<a
class="button button--ghost"
href="https://baserow.io/contact-sales"
target="_blank"
>
{{ $t('license.contactSalesMoreSeats') }}
</a>
</div>
</div>
</template>
<script>
import ProgressBar from '@baserow/modules/core/components/ProgressBar'
export default {
name: 'AutomaticLicenseSeats',
components: { ProgressBar },
props: {
license: {
type: Object,
required: true,
},
},
computed: {
paidSeatsStatus() {
return this.$t('license.automaticSeatsProgressBarStatus', {
seats: this.license.seats,
seats_taken: this.license.seats_taken,
free_users_count: this.license.free_users_count || 0,
})
},
paidSeatsUsedPercentage() {
return (this.license.seats_taken / this.license.seats) * 100
},
overSoftLimit() {
return this.license.seats_taken > this.license.seats
},
licenseType() {
return this.$registry.get('license', this.license.product_code)
},
},
}
</script>

View file

@ -0,0 +1,174 @@
<template>
<div>
<p>
{{ licenseType.getLicenseDescription(license) }}
</p>
<div class="license-detail__add">
<div
v-show="license.seats - license.seats_taken > 0"
class="license-detail__add-dropdown"
>
<div v-if="addUserLoading" class="loading-overlay"></div>
<PaginatedDropdown
ref="add"
:value="null"
:fetch-page="fetchUsers"
:not-selected-text="$t('license.addUser')"
:add-empty-item="false"
@input="addUser"
></PaginatedDropdown>
</div>
{{
$tc('license.seatLeft', leftSeats, {
count: leftSeats,
})
}}
</div>
<div
v-for="(licenseUser, index) in license.users"
:key="licenseUser.email"
class="license-detail__user"
>
<div class="license-detail__user-number">{{ index + 1 }}</div>
<div class="license-detail__user-name">
{{ licenseUser.first_name }}
</div>
<div class="license-detail__user-email">{{ licenseUser.email }}</div>
<div>
<div v-if="removingUser === licenseUser.id" class="loading"></div>
<a
v-else
class="license-detail__user-delete"
@click="removeUser(licenseUser)"
>
<i class="fas fa-trash"></i>
</a>
</div>
</div>
<div class="license-detail__actions">
<div v-if="actionLoading" class="loading"></div>
<template v-else>
<a
v-show="license.seats - license.seats_taken > 0"
class="margin-right-2"
@click="fillSeats()"
>{{ $t('license.fillSeats') }}</a
>
<a
v-show="license.seats - license.seats_taken < license.seats"
class="color-error"
@click="removeAllUsers()"
>{{ $t('license.removeAll') }}</a
>
</template>
</div>
</div>
</template>
<script>
import LicenseService from '@baserow_premium/services/license'
import { notifyIf } from '@baserow/modules/core/utils/error'
import PaginatedDropdown from '@baserow/modules/core/components/PaginatedDropdown'
export default {
name: 'ManualLicenseSeatForm',
components: { PaginatedDropdown },
props: {
license: {
type: Object,
required: true,
},
},
data() {
return {
user: null,
addUserLoading: false,
actionLoading: false,
checkLoading: false,
removingUser: -1,
}
},
computed: {
licenseType() {
return this.$registry.get('license', this.license.product_code)
},
leftSeats() {
return this.license.seats - this.license.seats_taken
},
},
methods: {
async addUser(event) {
this.addUserLoading = true
try {
const { data } = await LicenseService(this.$client).addUser(
this.license.id,
event.id
)
this.license.users.push(data)
this.license.seats_taken += 1
} catch (error) {
notifyIf(error)
}
this.addUserLoading = false
this.$nextTick(() => {
this.user = null
this.$refs.add.reset()
})
},
async removeUser(user) {
this.removingUser = user.id
try {
await LicenseService(this.$client).removeUser(this.license.id, user.id)
const index = this.license.users.findIndex((u) => u.id === user.id)
if (index !== undefined) {
this.license.seats_taken -= 1
this.license.users.splice(index, 1)
}
this.$refs.add.reset()
} catch (error) {
notifyIf(error)
} finally {
this.removingUser = -1
}
},
async fillSeats() {
this.actionLoading = true
try {
const { data } = await LicenseService(this.$client).fillSeats(
this.license.id
)
this.license.seats_taken += data.length
this.license.users.push(...data)
this.$refs.add.reset()
} catch (error) {
notifyIf(error)
}
this.actionLoading = false
},
async removeAllUsers() {
this.actionLoading = true
try {
await LicenseService(this.$client).removeAllUsers(this.license.id)
this.license.seats_taken = 0
this.license.users = []
this.$refs.add.reset()
} catch (error) {
notifyIf(error)
}
this.actionLoading = false
},
fetchUsers(page, search) {
return LicenseService(this.$client).lookupUsers(
this.license.id,
page,
search
)
},
},
}
</script>

View file

@ -42,6 +42,18 @@ export class LicenseType extends Registerable {
getOrder() {
throw new Error('Must be set by the implementing sub class.')
}
getSeatsManuallyAssigned() {
throw new Error('Must be set by the implementing sub class.')
}
getLicenseDescription(license) {
throw new Error('Must be set by the implementing sub class.')
}
getLicenseSeatOverflowWarning(license) {
throw new Error('Must be set by the implementing sub class.')
}
}
export class PremiumLicenseType extends LicenseType {
@ -79,4 +91,17 @@ export class PremiumLicenseType extends LicenseType {
getOrder() {
return 10
}
getSeatsManuallyAssigned() {
return true
}
getLicenseDescription(license) {
const { i18n } = this.app
return i18n.t('license.description', license)
}
getLicenseSeatOverflowWarning(license) {
return ''
}
}

View file

@ -229,7 +229,10 @@
"rowUsage": "Row usage",
"storeUsage": "Storage usage",
"disconnectLicense": "Disconnect license",
"disconnectDescription": "If you disconnect this license while it's active, the related users wont have access to the plan anymore. It will effectively remove the license. Please contact our support team at {contact} if you want to use this license in another self hosted instance."
"disconnectDescription": "If you disconnect this license while it's active, the related users wont have access to the plan anymore. It will effectively remove the license. Please contact our support team at {contact} if you want to use this license in another self hosted instance.",
"moreSeatsNeededTitle": "More Seats Needed",
"contactSalesMoreSeats": "Contact Sales for more seats",
"automaticSeatsProgressBarStatus": "{seats_taken} / {seats} paid seats and {free_users_count} free seats in use"
},
"viewDecoratorType": {
"leftBorderColor": "Left border",

View file

@ -6,68 +6,14 @@
</h1>
<div class="license-detail__users">
<h2>{{ $t('license.users') }}</h2>
<p>
{{ $t('license.description', license) }}
</p>
<div class="license-detail__add">
<div
v-show="license.seats - license.seats_taken > 0"
class="license-detail__add-dropdown"
>
<div v-if="addUserLoading" class="loading-overlay"></div>
<PaginatedDropdown
ref="add"
:value="null"
:fetch-page="fetchUsers"
:not-selected-text="$t('license.addUser')"
:add-empty-item="false"
@input="addUser"
></PaginatedDropdown>
</div>
{{
$tc('license.seatLeft', leftSeats, {
count: leftSeats,
})
}}
</div>
<div
v-for="(licenseUser, index) in license.users"
:key="licenseUser.email"
class="license-detail__user"
>
<div class="license-detail__user-number">{{ index + 1 }}</div>
<div class="license-detail__user-name">
{{ licenseUser.first_name }}
</div>
<div class="license-detail__user-email">{{ licenseUser.email }}</div>
<div>
<div v-if="removingUser === licenseUser.id" class="loading"></div>
<a
v-else
class="license-detail__user-delete"
@click="removeUser(licenseUser)"
>
<i class="fas fa-trash"></i>
</a>
</div>
</div>
<div class="license-detail__actions">
<div v-if="actionLoading" class="loading"></div>
<template v-else>
<a
v-show="license.seats - license.seats_taken > 0"
class="margin-right-2"
@click="fillSeats()"
>{{ $t('license.fillSeats') }}</a
>
<a
v-show="license.seats - license.seats_taken < license.seats"
class="color-error"
@click="removeAllUsers()"
>{{ $t('license.removeAll') }}</a
>
</template>
</div>
<ManualLicenseSeatsForm
v-if="licenseType.getSeatsManuallyAssigned()"
:license="license"
></ManualLicenseSeatsForm>
<AutomaticLicenseSeats
v-else
:license="license"
></AutomaticLicenseSeats>
</div>
<div class="license-detail__body">
<div class="license-detail__body-left">
@ -203,7 +149,7 @@
</div>
</div>
</div>
<div class="license-body__body-right">
<div class="license-detail__body-right">
<div class="delete-section">
<div class="delete-section__label">
<div class="delete-section__label-icon">
@ -237,12 +183,17 @@
<script>
import moment from '@baserow/modules/core/moment'
import { notifyIf } from '@baserow/modules/core/utils/error'
import PaginatedDropdown from '@baserow/modules/core/components/PaginatedDropdown'
import LicenseService from '@baserow_premium/services/license'
import DisconnectLicenseModal from '@baserow_premium/components/license/DisconnectLicenseModal'
import ManualLicenseSeatsForm from '@baserow_premium/components/license/ManualLicenseSeatForm'
import AutomaticLicenseSeats from '@baserow_premium/components/license/AutomaticLicenseSeats'
export default {
components: { DisconnectLicenseModal, PaginatedDropdown },
components: {
DisconnectLicenseModal,
ManualLicenseSeatsForm,
AutomaticLicenseSeats,
},
layout: 'app',
middleware: 'staff',
async asyncData({ params, app, error }) {
@ -259,16 +210,10 @@ export default {
data() {
return {
user: null,
addUserLoading: false,
actionLoading: false,
checkLoading: false,
removingUser: -1,
}
},
computed: {
leftSeats() {
return this.license.seats - this.license.seats_taken
},
licenseType() {
return this.$registry.get('license', this.license.product_code)
},
@ -284,79 +229,6 @@ export default {
return moment.utc(timestamp).local().format('ll LT')
},
fetchUsers(page, search) {
return LicenseService(this.$client).lookupUsers(
this.license.id,
page,
search
)
},
async addUser(event) {
this.addUserLoading = true
try {
const { data } = await LicenseService(this.$client).addUser(
this.license.id,
event.id
)
this.license.users.push(data)
this.license.seats_taken += 1
} catch (error) {
notifyIf(error)
}
this.addUserLoading = false
this.$nextTick(() => {
this.user = null
this.$refs.add.reset()
})
},
async removeUser(user) {
this.removingUser = user.id
try {
await LicenseService(this.$client).removeUser(this.license.id, user.id)
const index = this.license.users.findIndex((u) => u.id === user.id)
if (index !== undefined) {
this.license.seats_taken -= 1
this.license.users.splice(index, 1)
}
this.$refs.add.reset()
} catch (error) {
notifyIf(error)
} finally {
this.removingUser = -1
}
},
async fillSeats() {
this.actionLoading = true
try {
const { data } = await LicenseService(this.$client).fillSeats(
this.license.id
)
this.license.seats_taken += data.length
this.license.users.push(...data)
this.$refs.add.reset()
} catch (error) {
notifyIf(error)
}
this.actionLoading = false
},
async removeAllUsers() {
this.actionLoading = true
try {
await LicenseService(this.$client).removeAllUsers(this.license.id)
this.license.seats_taken = 0
this.license.users = []
this.$refs.add.reset()
} catch (error) {
notifyIf(error)
}
this.actionLoading = false
},
async check() {
this.checkLoading = true

View file

@ -182,6 +182,9 @@ export default {
licenseFeatureDescription(license) {
return this.getLicenseType(license).getFeaturesDescription()
},
licenseSeatsInfo(license) {
return this.getLicenseType(license).getFeaturesDescription()
},
},
}
</script>

View file

@ -12,6 +12,10 @@
border-radius: 5px;
height: 100%;
transition-timing-function: linear;
&--overflow {
background-color: $color-error-500;
}
}
.progress-bar__status-text {

View file

@ -2,9 +2,14 @@
<div class="progress-bar">
<div
class="progress-bar__inner"
:class="
showOverflow && overflowing ? 'progress-bar__inner--overflow' : ''
"
:style="{
width: `${value}%`,
'transition-duration': [100, 0].includes(value) ? '0s' : '1s',
width: `${constrainedValue}%`,
'transition-duration': [100, 0].includes(constrainedValue)
? '0s'
: '1s',
}"
></div>
<span class="progress-bar__status-text">
@ -32,11 +37,22 @@ export default {
required: false,
default: true,
},
showOverflow: {
required: false,
type: Boolean,
default: false,
},
},
computed: {
constrainedValue() {
return this.showOverflow ? Math.min(this.value, 100) : this.value
},
displayValue() {
return Math.round(this.value)
},
overflowing() {
return this.value > 100
},
},
}
</script>