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:
parent
139d9865f2
commit
ea38da6402
26 changed files with 760 additions and 225 deletions
backend/tests/baserow/contrib/database/view
docker-compose.dev.ymlenterprise
backend
src/baserow_enterprise
tests/baserow_enterprise_tests
web-frontend/modules/baserow_enterprise
premium
backend
src/baserow_premium
api/license
license
tests/baserow_premium_tests
web-frontend/modules/baserow_premium
web-frontend/modules/core
|
@ -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")):
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.",
|
||||
)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 ''
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 won’t 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 won’t 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",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -182,6 +182,9 @@ export default {
|
|||
licenseFeatureDescription(license) {
|
||||
return this.getLicenseType(license).getFeaturesDescription()
|
||||
},
|
||||
licenseSeatsInfo(license) {
|
||||
return this.getLicenseType(license).getFeaturesDescription()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -12,6 +12,10 @@
|
|||
border-radius: 5px;
|
||||
height: 100%;
|
||||
transition-timing-function: linear;
|
||||
|
||||
&--overflow {
|
||||
background-color: $color-error-500;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar__status-text {
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue