diff --git a/.eslintrc.json b/.eslintrc.json
index 68c8ec5c8..d7c82cd0e 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -18,7 +18,8 @@
"@sb/webapp-documents/**",
"@sb/webapp-finances/**",
"@sb/webapp-generative-ai/**",
- "@sb/webapp-notifications/**"
+ "@sb/webapp-notifications/**",
+ "@sb/webapp-tenants/**"
],
"depConstraints": [
{
diff --git a/.github/workflows/webapp.yml b/.github/workflows/webapp.yml
index f2e062dd8..4549b3b5b 100644
--- a/.github/workflows/webapp.yml
+++ b/.github/workflows/webapp.yml
@@ -88,6 +88,7 @@ jobs:
- webapp-emails
- webapp-finances
- webapp-generative-ai
+ - webapp-tenants
steps:
- uses: actions/checkout@v3
with:
@@ -130,5 +131,6 @@ jobs:
SONAR_WEBAPP_FINANCES_PROJECT_KEY: ${{ vars.SONAR_WEBAPP_FINANCES_PROJECT_KEY }}
SONAR_WEBAPP_GENERATIVE_AI_PROJECT_KEY: ${{ vars.SONAR_WEBAPP_GENERATIVE_AI_PROJECT_KEY }}
SONAR_WEBAPP_NOTIFICATIONS_PROJECT_KEY: ${{ vars.SONAR_WEBAPP_NOTIFICATIONS_PROJECT_KEY }}
+ SONAR_WEBAPP_TENANTS_PROJECT_KEY: ${{ vars.SONAR_WEBAPP_TENANTS_PROJECT_KEY }}
with:
projectBaseDir: "packages/webapp-libs/${{ matrix.webapp-lib-name }}"
diff --git a/.versionrc.js b/.versionrc.js
index bd7aab653..6f16cd9f4 100644
--- a/.versionrc.js
+++ b/.versionrc.js
@@ -49,6 +49,10 @@ module.exports = {
filename: './packages/webapp-libs/webapp-notifications/package.json',
type: 'json',
},
+ {
+ filename: './packages/webapp-libs/webapp-tenants/package.json',
+ type: 'json',
+ },
{
filename: './packages/workers/package.json',
diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml
index d4de51899..bb941d0dd 100644
--- a/bitbucket-pipelines.yml
+++ b/bitbucket-pipelines.yml
@@ -21,6 +21,7 @@ definitions:
pnpmwebappemails: $BITBUCKET_CLONE_DIR/.pnpm-store
pnpmwebappfinances: $BITBUCKET_CLONE_DIR/.pnpm-store
pnpmwebappgenerativeai: $BITBUCKET_CLONE_DIR/.pnpm-store
+ pnpmwebapptenants: $BITBUCKET_CLONE_DIR/.pnpm-store
pnpmworkers: $BITBUCKET_CLONE_DIR/.pnpm-store
pnpminfracore: $BITBUCKET_CLONE_DIR/.pnpm-store
pnpminfrashared: $BITBUCKET_CLONE_DIR/.pnpm-store
@@ -306,6 +307,30 @@ definitions:
- pnpmwebappgenerativeai
- clis
- sonar
+ - step: &webappTenantsTest
+ name: 'webapp-tenants: Lint & test'
+ image: atlassian/default-image:4
+ size: 2x
+ script:
+ - *initializeStep
+ - pnpm install
+ --include-workspace-root
+ --frozen-lockfile
+ --filter=webapp-tenants...
+ - pnpm nx run webapp-tenants:lint
+ - pnpm nx run webapp-tenants:type-check
+ - pnpm nx run webapp-tenants:test --watchAll=false --maxWorkers=20% --coverage
+ - if [ -z "${SONAR_ORGANIZATION}" ]; then exit 0; fi
+ - pipe: sonarsource/sonarcloud-scan:1.4.0
+ variables:
+ SONAR_ORGANIZATION: ${SONAR_ORGANIZATION}
+ SONAR_WEBAPP_TENANTS_PROJECT_KEY: ${SONAR_WEBAPP_TENANTS_PROJECT_KEY}
+ SONAR_TOKEN: ${SONAR_TOKEN}
+ EXTRA_ARGS: '-Dsonar.projectBaseDir=packages/webapp-libs/webapp-tenants'
+ caches:
+ - pnpmwebapptenants
+ - clis
+ - sonar
- step: &backendBuildAndTest
name: 'backend: Build image & run tests'
image: atlassian/default-image:4
@@ -501,6 +526,7 @@ pipelines:
- step: *webappEmailsTest
- step: *webappFinancesTest
- step: *webappGenAiTest
+ - step: *webappTenantsTest
- step: *backendBuildAndTest
@@ -531,6 +557,7 @@ pipelines:
- step: *webappEmailsTest
- step: *webappFinancesTest
- step: *webappGenAiTest
+ - step: *webappTenantsTest
- step: *backendBuildAndTest
diff --git a/package.json b/package.json
index 0ef7a2fe5..c93adead3 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"postinstall": "node packages/internal/cli/scripts/build.js"
},
"devDependencies": {
- "@apollo/client": "^3.8.8",
+ "@apollo/client": "^3.9.6",
"@apollo/rover": "^0.19.1",
"@babel/preset-react": "^7.24.1",
"@graphql-codegen/cli": "^5.0.0",
diff --git a/packages/backend/apps/demo/migrations/0003_cruddemoitem_tenant.py b/packages/backend/apps/demo/migrations/0003_cruddemoitem_tenant.py
new file mode 100644
index 000000000..c13a18a16
--- /dev/null
+++ b/packages/backend/apps/demo/migrations/0003_cruddemoitem_tenant.py
@@ -0,0 +1,40 @@
+# Generated by Django 4.2 on 2024-03-26 12:10
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+from apps.multitenancy.constants import TenantType
+
+
+def populate_tenant(apps, schema_editor):
+ CrudDemoItem = apps.get_model('demo', 'CrudDemoItem')
+ Tenant = apps.get_model('multitenancy', 'Tenant')
+
+ for item in CrudDemoItem.objects.all():
+ if not item.tenant:
+ default_tenant = Tenant.objects.filter(type=TenantType.DEFAULT, creator=item.created_by).first()
+ item.tenant = default_tenant
+ item.save()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('multitenancy', '0005_tenant_billing_email'),
+ ('demo', '0002_initial')
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cruddemoitem',
+ name='tenant',
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='%(class)s_set',
+ to='multitenancy.tenant',
+ verbose_name='Tenant',
+ ),
+ ),
+ migrations.RunPython(populate_tenant, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/packages/backend/apps/demo/models.py b/packages/backend/apps/demo/models.py
index 439389a79..9d5682f34 100644
--- a/packages/backend/apps/demo/models.py
+++ b/packages/backend/apps/demo/models.py
@@ -7,11 +7,12 @@
from apps.content import models as content_models
from common.storages import UniqueFilePathGenerator
+from common.models import TimestampedMixin, TenantDependentModelMixin
User = get_user_model()
-class CrudDemoItem(models.Model):
+class CrudDemoItem(TenantDependentModelMixin, models.Model):
id = hashid_field.HashidAutoField(primary_key=True)
name = models.CharField(max_length=255)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True)
@@ -24,14 +25,11 @@ def __init__(self, *args, **kwargs):
self.edited_by: Optional[User] = None
-class ContentfulDemoItemFavorite(models.Model):
+class ContentfulDemoItemFavorite(TimestampedMixin, models.Model):
id = hashid_field.HashidAutoField(primary_key=True)
item = models.ForeignKey(content_models.DemoItem, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
- created_at = models.DateTimeField(auto_now_add=True)
- updated_at = models.DateTimeField(auto_now=True)
-
class Meta:
unique_together = [['item', 'user']]
diff --git a/packages/backend/apps/demo/notifications.py b/packages/backend/apps/demo/notifications.py
index 43dc57c15..266685353 100644
--- a/packages/backend/apps/demo/notifications.py
+++ b/packages/backend/apps/demo/notifications.py
@@ -1,4 +1,3 @@
-from django.contrib.auth import get_user_model
from graphql_relay import to_global_id
from apps.notifications import sender
@@ -7,10 +6,10 @@
def send_new_entry_created_notification(entry: models.CrudDemoItem):
- User = get_user_model()
- for admin in User.objects.filter_admins():
+ tenant = entry.tenant
+ for owner in tenant.owners:
sender.send_notification(
- user=admin,
+ user=owner,
type=constants.Notification.CRUD_ITEM_CREATED.value,
data={
"id": to_global_id('CrudDemoItemType', str(entry.id)),
@@ -23,8 +22,8 @@ def send_new_entry_created_notification(entry: models.CrudDemoItem):
def send_entry_updated_notification(entry: models.CrudDemoItem):
if not entry.edited_by:
return
- User = get_user_model()
- users_to_be_notified = set(User.objects.filter_admins()) | {entry.created_by}
+ tenant = entry.tenant
+ users_to_be_notified = set(tenant.owners) | {entry.created_by}
for user in users_to_be_notified:
if user and user != entry.edited_by:
sender.send_notification(
diff --git a/packages/backend/apps/demo/schema.py b/packages/backend/apps/demo/schema.py
index 5013219ab..45a5c4b58 100644
--- a/packages/backend/apps/demo/schema.py
+++ b/packages/backend/apps/demo/schema.py
@@ -2,10 +2,12 @@
from django.shortcuts import get_object_or_404
from graphene import relay
from graphene_django import DjangoObjectType
-from graphql_relay import to_global_id
+from graphql_relay import to_global_id, from_global_id
from apps.content import models as content_models
from common.graphql import mutations
+from common.acl.policies import IsTenantMemberAccess
+from common.graphql.acl import permission_classes
from . import models, serializers
@@ -42,7 +44,7 @@ class Meta:
node = ContentfulDemoItemFavoriteType
-class CreateCrudDemoItemMutation(mutations.CreateModelMutation):
+class CreateCrudDemoItemMutation(mutations.CreateTenantDependentModelMutation):
class Meta:
serializer_class = serializers.CrudDemoItemSerializer
edge_class = CrudDemoItemConnection.Edge
@@ -103,26 +105,28 @@ def mutate_and_get_payload(cls, root, info, id):
return cls(deleted_ids=[id])
-class UpdateCrudDemoItemMutation(mutations.UpdateModelMutation):
+class UpdateCrudDemoItemMutation(mutations.UpdateTenantDependentModelMutation):
class Meta:
serializer_class = serializers.CrudDemoItemSerializer
edge_class = CrudDemoItemConnection.Edge
-class DeleteCrudDemoItemMutation(mutations.DeleteModelMutation):
+class DeleteCrudDemoItemMutation(mutations.DeleteTenantDependentModelMutation):
class Meta:
model = models.CrudDemoItem
class Query(graphene.ObjectType):
- crud_demo_item = graphene.relay.Node.Field(CrudDemoItemType)
- all_crud_demo_items = graphene.relay.ConnectionField(CrudDemoItemConnection)
+ crud_demo_item = graphene.Field(CrudDemoItemType, id=graphene.ID(), tenant_id=graphene.ID())
+ all_crud_demo_items = graphene.relay.ConnectionField(CrudDemoItemConnection, tenant_id=graphene.ID())
all_contentful_demo_item_favorites = graphene.relay.ConnectionField(ContentfulDemoItemFavoriteConnection)
all_document_demo_items = graphene.relay.ConnectionField(DocumentDemoItemConnection)
@staticmethod
- def resolve_all_crud_demo_items(root, info, **kwargs):
- return models.CrudDemoItem.objects.all()
+ @permission_classes(IsTenantMemberAccess)
+ def resolve_all_crud_demo_items(root, info, tenant_id, **kwargs):
+ _, pk = from_global_id(tenant_id)
+ return models.CrudDemoItem.objects.filter(tenant_id=pk).all()
@staticmethod
def resolve_all_contentful_demo_item_favorites(root, info, **kwargs):
@@ -132,11 +136,22 @@ def resolve_all_contentful_demo_item_favorites(root, info, **kwargs):
def resolve_all_document_demo_items(root, info, **kwargs):
return info.context.user.documents.all()
+ @staticmethod
+ @permission_classes(IsTenantMemberAccess)
+ def resolve_crud_demo_item(root, info, id, tenant_id, **kwargs):
+ _, pk = from_global_id(id)
+ _, tenant_pk = from_global_id(tenant_id)
+ return models.CrudDemoItem.objects.filter(pk=pk, tenant=tenant_pk).first()
-class Mutation(graphene.ObjectType):
+
+@permission_classes(IsTenantMemberAccess)
+class TenantMemberMutation(graphene.ObjectType):
create_crud_demo_item = CreateCrudDemoItemMutation.Field()
update_crud_demo_item = UpdateCrudDemoItemMutation.Field()
delete_crud_demo_item = DeleteCrudDemoItemMutation.Field()
+
+
+class Mutation(graphene.ObjectType):
create_document_demo_item = CreateDocumentDemoItemMutation.Field()
delete_document_demo_item = DeleteDocumentDemoItemMutation.Field()
create_favorite_contentful_demo_item = CreateFavoriteContentfulDemoItemMutation.Field()
diff --git a/packages/backend/apps/demo/serializers.py b/packages/backend/apps/demo/serializers.py
index 3c63b6f8e..d23fea77a 100644
--- a/packages/backend/apps/demo/serializers.py
+++ b/packages/backend/apps/demo/serializers.py
@@ -9,6 +9,7 @@
class CrudDemoItemSerializer(serializers.ModelSerializer):
id = hidrest.HashidSerializerCharField(source_field="users.User.id", read_only=True)
+ tenant_id = hidrest.HashidSerializerCharField()
created_by = serializers.HiddenField(default=serializers.CurrentUserDefault())
def update(self, instance, validated_data):
@@ -17,7 +18,7 @@ def update(self, instance, validated_data):
class Meta:
model = models.CrudDemoItem
- fields = ('id', 'name', 'created_by')
+ fields = ('id', 'tenant_id', 'name', 'created_by')
class DocumentDemoItemSerializer(serializers.ModelSerializer):
diff --git a/packages/backend/apps/demo/tests/factories.py b/packages/backend/apps/demo/tests/factories.py
index d45bf8639..30c198c2c 100644
--- a/packages/backend/apps/demo/tests/factories.py
+++ b/packages/backend/apps/demo/tests/factories.py
@@ -4,10 +4,12 @@
from apps.users.tests import factories as user_factories
from apps.content.tests import factories as content_factories
+from apps.multitenancy.tests import factories as multitenancy_factories
class CrudDemoItemFactory(factory.django.DjangoModelFactory):
name = factory.Faker('pystr')
+ tenant = factory.SubFactory(multitenancy_factories.TenantFactory)
class Meta:
model = models.CrudDemoItem
diff --git a/packages/backend/apps/demo/tests/test_schema.py b/packages/backend/apps/demo/tests/test_schema.py
index b4e19dd25..2f434dc71 100644
--- a/packages/backend/apps/demo/tests/test_schema.py
+++ b/packages/backend/apps/demo/tests/test_schema.py
@@ -6,19 +6,26 @@
from graphene_file_upload.django.testing import file_graphql_query
from graphql_relay import to_global_id, from_global_id
from .. import models, constants
+from apps.multitenancy.constants import TenantType, TenantUserRole
pytestmark = pytest.mark.django_db
class TestAllCrudDemoItemsQuery:
- def test_returns_all_items(self, graphene_client, crud_demo_item_factory, user):
- items = crud_demo_item_factory.create_batch(3)
-
+ def test_returns_all_items(
+ self, graphene_client, crud_demo_item_factory, tenant_factory, tenant_membership_factory, user
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_2 = tenant_factory(name="Tenant 2", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ accessible_items = crud_demo_item_factory.create_batch(3, tenant=tenant)
+ crud_demo_item_factory.create_batch(3, tenant=tenant_2)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
graphene_client.force_authenticate(user)
executed = graphene_client.query(
"""
- query {
- allCrudDemoItems {
+ query($tenantId: ID!) {
+ allCrudDemoItems(tenantId: $tenantId) {
edges {
node {
id
@@ -27,7 +34,8 @@ def test_returns_all_items(self, graphene_client, crud_demo_item_factory, user):
}
}
}
- """
+ """,
+ variable_values={"tenantId": to_global_id("TenantType", tenant.id)},
)
assert executed == {
@@ -40,52 +48,80 @@ def test_returns_all_items(self, graphene_client, crud_demo_item_factory, user):
"name": item.name,
}
}
- for item in items
+ for item in accessible_items
]
}
}
}
+ def test_returns_all_items_without_membership(self, graphene_client, crud_demo_item_factory, tenant_factory, user):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_2 = tenant_factory(name="Tenant 2", type=TenantType.ORGANIZATION)
+ crud_demo_item_factory.create_batch(3, tenant=tenant)
+ crud_demo_item_factory.create_batch(3, tenant=tenant_2)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+ executed = graphene_client.query(
+ """
+ query($tenantId: ID!) {
+ allCrudDemoItems(tenantId: $tenantId) {
+ edges {
+ node {
+ id
+ name
+ }
+ }
+ }
+ }
+ """,
+ variable_values={"tenantId": to_global_id("TenantType", tenant.id)},
+ )
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
class TestCrudDemoItemQuery:
CRUD_DEMO_ITEM_QUERY = """
- query($id: ID!) {
- crudDemoItem(id: $id) {
+ query($id: ID!, $tenantId: ID) {
+ crudDemoItem(id: $id, tenantId: $tenantId) {
id
name
}
}
"""
- def test_return_error_for_not_authorized_user(self, graphene_client, crud_demo_item):
+ def test_return_error_for_not_authorized_user(self, graphene_client, crud_demo_item, tenant):
item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id))
executed = graphene_client.query(
self.CRUD_DEMO_ITEM_QUERY,
- variable_values={"id": item_global_id},
+ variable_values={"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)},
)
assert executed["errors"]
assert executed["errors"][0]["message"] == "permission_denied"
- def test_return_none_if_item_does_not_exist(self, graphene_client, user):
+ def test_return_none_if_item_does_not_exist(self, graphene_client, user, tenant):
item_global_id = to_global_id("CrudDemoItemType", "invalid-id")
graphene_client.force_authenticate(user)
executed = graphene_client.query(
self.CRUD_DEMO_ITEM_QUERY,
- variable_values={"id": item_global_id},
+ variable_values={"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)},
)
assert executed["data"] == {"crudDemoItem": None}
- def test_return_item(self, graphene_client, crud_demo_item, user):
+ def test_return_item(self, graphene_client, crud_demo_item_factory, user, tenant, tenant_membership_factory):
+ crud_demo_item = crud_demo_item_factory(tenant=tenant)
item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id))
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
executed = graphene_client.query(
self.CRUD_DEMO_ITEM_QUERY,
- variable_values={"id": item_global_id},
+ variable_values={"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)},
)
assert executed == {
@@ -97,6 +133,36 @@ def test_return_item(self, graphene_client, crud_demo_item, user):
}
}
+ def test_tenant_no_membership(self, graphene_client, crud_demo_item_factory, user, tenant):
+ crud_demo_item = crud_demo_item_factory(tenant=tenant)
+ item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id))
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+ executed = graphene_client.query(
+ self.CRUD_DEMO_ITEM_QUERY,
+ variable_values={"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)},
+ )
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_wrong_tenant_id(
+ self, graphene_client, crud_demo_item_factory, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory()
+ tenant_second = tenant_factory()
+ crud_demo_item = crud_demo_item_factory(tenant=tenant_second)
+ item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id))
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
+ executed = graphene_client.query(
+ self.CRUD_DEMO_ITEM_QUERY,
+ variable_values={"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)},
+ )
+ assert executed["data"] == {"crudDemoItem": None}
+
class TestCreateCrudDemoItemMutation:
CREATE_MUTATION = """
@@ -105,6 +171,9 @@ class TestCreateCrudDemoItemMutation:
crudDemoItem {
id
name
+ tenant {
+ id
+ }
}
}
}
@@ -122,35 +191,48 @@ class TestCreateCrudDemoItemMutation:
}
"""
- @pytest.fixture
- def input_data(self) -> dict:
- return {"name": "Item name"}
-
- def test_create_new_item(self, graphene_client, user, input_data):
+ def test_create_new_item(self, graphene_client, user, tenant, tenant_membership_factory):
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
executed = graphene_client.mutate(
self.CREATE_MUTATION,
- variable_values={"input": input_data},
+ variable_values={"input": {"name": "Item name", "tenantId": to_global_id("TenantType", tenant.id)}},
)
assert executed["data"]["createCrudDemoItem"]
assert executed["data"]["createCrudDemoItem"]["crudDemoItem"]
- assert executed["data"]["createCrudDemoItem"]["crudDemoItem"]["name"] == input_data["name"]
+ assert executed["data"]["createCrudDemoItem"]["crudDemoItem"]["name"] == "Item name"
item_global_id = executed["data"]["createCrudDemoItem"]["crudDemoItem"]["id"]
_, pk = from_global_id(item_global_id)
item = models.CrudDemoItem.objects.get(pk=pk)
- assert item.name == input_data["name"]
+ assert item.name == "Item name"
+ assert item.tenant == tenant
- def test_create_new_item_sends_notification(self, graphene_client, user_factory, input_data):
- user = user_factory(has_avatar=True)
- admin = user_factory(admin=True)
+ def test_create_new_item_without_membership(self, graphene_client, user, tenant):
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+ executed = graphene_client.mutate(
+ self.CREATE_MUTATION,
+ variable_values={"input": {"name": "Item name", "tenantId": to_global_id("TenantType", tenant.id)}},
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_create_new_item_sends_notification(self, graphene_client, user_factory, tenant, tenant_membership_factory):
+ owner = user_factory()
+ tenant_membership_factory(tenant=tenant, user=owner, role=TenantUserRole.OWNER)
+ user = user_factory(has_avatar=True)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
executed = graphene_client.mutate(
self.CREATE_MUTATION,
- variable_values={"input": input_data},
+ variable_values={"input": {"name": "Item name", "tenantId": to_global_id("TenantType", tenant.id)}},
)
item_global_id = executed["data"]["createCrudDemoItem"]["crudDemoItem"]["id"]
@@ -160,7 +242,7 @@ def test_create_new_item_sends_notification(self, graphene_client, user_factory,
assert Notification.objects.count() == 1
notification = Notification.objects.first()
assert notification.type == constants.Notification.CRUD_ITEM_CREATED.value
- assert notification.user == admin
+ assert notification.user == owner
assert notification.data == {
"id": item_global_id,
"name": item.name,
@@ -175,6 +257,9 @@ class TestUpdateCrudDemoItemMutation:
crudDemoItem {
id
name
+ tenant {
+ id
+ }
}
}
}
@@ -194,18 +279,22 @@ class TestUpdateCrudDemoItemMutation:
@pytest.fixture
def input_data_factory(self):
- def _factory(crud_demo_item, name="New item name") -> dict:
+ def _factory(crud_demo_item, name="New item name", tenant_id=None) -> dict:
return {
"id": to_global_id("CrudDemoItemType", str(crud_demo_item.id)),
"name": name,
+ "tenantId": tenant_id or to_global_id("TenantType", crud_demo_item.tenant_id),
}
return _factory
- def test_update_existing_item(self, graphene_client, crud_demo_item, user, input_data_factory):
+ def test_update_existing_item(
+ self, graphene_client, crud_demo_item, user, tenant_membership_factory, tenant, input_data_factory
+ ):
input_data = input_data_factory(crud_demo_item)
-
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
executed = graphene_client.mutate(
self.UPDATE_MUTATION,
variable_values={"input": input_data},
@@ -216,19 +305,43 @@ def test_update_existing_item(self, graphene_client, crud_demo_item, user, input
assert executed["data"]["updateCrudDemoItem"]
assert executed["data"]["updateCrudDemoItem"]["crudDemoItem"]
assert executed["data"]["updateCrudDemoItem"]["crudDemoItem"]["name"] == input_data["name"]
+ assert from_global_id(executed["data"]["updateCrudDemoItem"]["crudDemoItem"]["tenant"]["id"])[1] == tenant.id
assert crud_demo_item.name == input_data["name"]
+ def test_update_existing_item_without_membership(
+ self, graphene_client, user, tenant, crud_demo_item, input_data_factory
+ ):
+ input_data = input_data_factory(crud_demo_item)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+ executed = graphene_client.mutate(
+ self.UPDATE_MUTATION,
+ variable_values={"input": input_data},
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
def test_update_existing_item_sends_notification_to_admins_and_creator(
- self, graphene_client, crud_demo_item_factory, user_factory, input_data_factory
+ self,
+ graphene_client,
+ crud_demo_item_factory,
+ user_factory,
+ input_data_factory,
+ tenant_membership_factory,
+ tenant,
):
user = user_factory(has_avatar=True)
other_user = user_factory(has_avatar=True)
- admins = user_factory.create_batch(2, admin=True)
- crud_demo_item = crud_demo_item_factory(created_by=user)
+ owners = user_factory.create_batch(2)
+ for owner in owners:
+ tenant_membership_factory(tenant=tenant, user=owner, role=TenantUserRole.OWNER)
+ crud_demo_item = crud_demo_item_factory(created_by=user, tenant=tenant)
item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id))
input_data = input_data_factory(crud_demo_item)
-
+ tenant_membership_factory(tenant=tenant, user=other_user, role=TenantUserRole.MEMBER)
graphene_client.force_authenticate(other_user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
graphene_client.mutate(
self.UPDATE_MUTATION,
variable_values={"input": input_data},
@@ -243,26 +356,36 @@ def test_update_existing_item_sends_notification_to_admins_and_creator(
}
assert notification.issuer == other_user
- assert Notification.objects.filter(user=admins[0], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists()
- assert Notification.objects.filter(user=admins[1], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists()
+ assert Notification.objects.filter(user=owners[0], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists()
+ assert Notification.objects.filter(user=owners[1], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists()
def test_update_existing_item_sends_notification_to_admins_skipping_creator_if_he_is_the_one_updating(
- self, graphene_client, crud_demo_item_factory, user_factory, input_data_factory
+ self,
+ graphene_client,
+ crud_demo_item_factory,
+ user_factory,
+ input_data_factory,
+ tenant,
+ tenant_membership_factory,
):
user = user_factory()
- crud_demo_item = crud_demo_item_factory(created_by=user)
- admins = user_factory.create_batch(2, admin=True)
+ crud_demo_item = crud_demo_item_factory(created_by=user, tenant=tenant)
+ owners = user_factory.create_batch(2)
+ for owner in owners:
+ tenant_membership_factory(tenant=tenant, user=owner, role=TenantUserRole.OWNER)
input_data = input_data_factory(crud_demo_item)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
graphene_client.mutate(
self.UPDATE_MUTATION,
variable_values={"input": input_data},
)
assert Notification.objects.count() == 2
- assert Notification.objects.filter(user=admins[0], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists()
- assert Notification.objects.filter(user=admins[1], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists()
+ assert Notification.objects.filter(user=owners[0], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists()
+ assert Notification.objects.filter(user=owners[1], type=constants.Notification.CRUD_ITEM_UPDATED.value).exists()
class TestDeleteCrudDemoItemMutation:
@@ -274,24 +397,47 @@ class TestDeleteCrudDemoItemMutation:
}
"""
- def test_deleting_item(self, graphene_client, crud_demo_item, user):
+ def test_deleting_item(self, graphene_client, crud_demo_item, user, tenant_membership_factory):
item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id))
+ tenant = crud_demo_item.tenant
+
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
executed = graphene_client.mutate(
self.DELETE_MUTATION,
- variable_values={"input": {"id": item_global_id}},
+ variable_values={"input": {"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)}},
)
assert executed == {"data": {"deleteCrudDemoItem": {"deletedIds": [item_global_id]}}}
assert not models.CrudDemoItem.objects.filter(id=crud_demo_item.id).exists()
+ def test_deleting_item_without_membership(self, graphene_client, crud_demo_item, user):
+ item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id))
+ tenant = crud_demo_item.tenant
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+
+ executed = graphene_client.mutate(
+ self.DELETE_MUTATION,
+ variable_values={"input": {"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)}},
+ )
+
+ assert len(executed["errors"]) == 1
+ assert executed["errors"][0]["message"] == "permission_denied"
+ assert executed["errors"][0]["path"] == ["deleteCrudDemoItem"]
+ assert executed["data"] == {"deleteCrudDemoItem": None}
+ assert models.CrudDemoItem.objects.filter(id=crud_demo_item.id).exists()
+
def test_deleting_item_by_not_authorized_user(self, graphene_client, crud_demo_item):
item_global_id = to_global_id("CrudDemoItemType", str(crud_demo_item.id))
+ tenant = crud_demo_item.tenant
executed = graphene_client.mutate(
self.DELETE_MUTATION,
- variable_values={"input": {"id": item_global_id}},
+ variable_values={"input": {"id": item_global_id, "tenantId": to_global_id("TenantType", tenant.id)}},
)
assert len(executed["errors"]) == 1
diff --git a/packages/backend/apps/finances/schema.py b/packages/backend/apps/finances/schema.py
index d34214cf8..3867e9e0e 100644
--- a/packages/backend/apps/finances/schema.py
+++ b/packages/backend/apps/finances/schema.py
@@ -11,7 +11,7 @@
from rest_framework.generics import get_object_or_404
from stripe.error import InvalidRequestError
-from common.acl.policies import AnyoneFullAccess
+from common.acl.policies import AnyoneFullAccess, IsTenantOwnerAccess
from common.graphql import mutations
from common.graphql.acl import permission_classes
from . import constants
@@ -189,26 +189,26 @@ class Meta:
node = StripeChargeType
-class ChangeActiveSubscriptionMutation(mutations.UpdateModelMutation):
+class ChangeActiveSubscriptionMutation(mutations.UpdateTenantDependentModelMutation):
class Meta:
- serializer_class = serializers.UserSubscriptionScheduleSerializer
+ serializer_class = serializers.TenantSubscriptionScheduleSerializer
edge_class = SubscriptionScheduleConnection.Edge
require_id_field = False
@classmethod
def get_object(cls, model_class, root, info, **input):
- return subscriptions.get_schedule(user=info.context.user)
+ return subscriptions.get_schedule(tenant=info.context.tenant)
-class CancelActiveSubscriptionMutation(mutations.UpdateModelMutation):
+class CancelActiveSubscriptionMutation(mutations.UpdateTenantDependentModelMutation):
class Meta:
- serializer_class = serializers.CancelUserActiveSubscriptionSerializer
+ serializer_class = serializers.CancelTenantActiveSubscriptionSerializer
edge_class = SubscriptionScheduleConnection.Edge
require_id_field = False
@classmethod
def get_object(cls, model_class, root, info, **input):
- return subscriptions.get_schedule(user=info.context.user)
+ return subscriptions.get_schedule(tenant=info.context.tenant)
class PaymentMethodConnection(graphene.Connection):
@@ -218,8 +218,8 @@ class Meta:
class PaymentMethodGetObjectMixin:
@classmethod
- def get_payment_method(cls, id, user):
- filter_kwargs = {"id": id, "customer__subscriber": user}
+ def get_payment_method(cls, id, tenant):
+ filter_kwargs = {"id": id, "customer__subscriber": tenant}
queryset = djstripe_models.PaymentMethod.objects.filter(**filter_kwargs)
if not queryset.exists():
try:
@@ -250,6 +250,7 @@ class UpdateDefaultPaymentMethodMutation(PaymentMethodGetObjectMixin, mutations.
class Input:
id = graphene.String()
+ tenant_id = graphene.String(required=True)
class Meta:
serializer_class = serializers.UpdateDefaultPaymentMethodSerializer
@@ -259,56 +260,62 @@ class Meta:
@classmethod
def get_object(cls, model_class, info, *args, **kwargs):
- return cls.get_payment_method(kwargs["id"], info.context.user)
+ return cls.get_payment_method(kwargs["id"], info.context.tenant)
@classmethod
def mutate_and_get_payload(cls, root, info, **input):
super().mutate_and_get_payload(root, info, **input)
return UpdateDefaultPaymentMethodMutation(
- active_subscription=subscriptions.get_schedule(user=info.context.user),
+ active_subscription=subscriptions.get_schedule(tenant=info.context.tenant),
payment_method_edge=SubscriptionScheduleConnection.Edge(
cursor=offset_to_cursor(0),
- node=cls.get_payment_method(input["id"], info.context.user),
+ node=cls.get_payment_method(input["id"], info.context.tenant),
),
)
-class DeletePaymentMethodMutation(PaymentMethodGetObjectMixin, mutations.DeleteModelMutation):
+class DeletePaymentMethodMutation(PaymentMethodGetObjectMixin, mutations.DeleteTenantDependentModelMutation):
active_subscription = graphene.Field(SubscriptionScheduleType)
class Meta:
model = djstripe_models.PaymentMethod
+ class Input:
+ id = graphene.String()
+ tenant_id = graphene.String(required=True)
+
@classmethod
@transaction.atomic
- def mutate_and_get_payload(cls, root, info, id):
- obj = cls.get_payment_method(id, info.context.user)
+ def mutate_and_get_payload(cls, root, info, id, **input):
+ if "tenant_id" in input:
+ _, input["tenant_id"] = from_global_id(input["tenant_id"])
+ obj = cls.get_payment_method(id, info.context.tenant)
pk = obj.pk
customers.remove_payment_method(payment_method=obj)
obj.delete()
return cls(
- active_subscription=subscriptions.get_schedule(user=info.context.user),
+ active_subscription=subscriptions.get_schedule(tenant=info.context.tenant),
deleted_ids=[to_global_id("StripePaymentMethodType", str(pk))],
)
-class CreatePaymentIntentMutation(mutations.CreateModelMutation):
+class CreatePaymentIntentMutation(mutations.CreateTenantDependentModelMutation):
class Meta:
model = djstripe_models.PaymentIntent
serializer_class = serializers.PaymentIntentSerializer
-class UpdatePaymentIntentMutation(mutations.UpdateModelMutation):
+class UpdatePaymentIntentMutation(mutations.UpdateTenantDependentModelMutation):
class Meta:
model = djstripe_models.PaymentIntent
serializer_class = serializers.PaymentIntentSerializer
@classmethod
def get_queryset(cls, model_class, root, info, **input):
- return djstripe_models.PaymentIntent.objects.filter(customer__subscriber=info.context.user)
+ return djstripe_models.PaymentIntent.objects.filter(customer__subscriber=info.context.tenant)
-class CreateSetupIntentMutation(mutations.CreateModelMutation):
+class CreateSetupIntentMutation(mutations.CreateTenantDependentModelMutation):
class Meta:
model = djstripe_models.SetupIntent
serializer_class = serializers.SetupIntentSerializer
@@ -316,11 +323,11 @@ class Meta:
class Query(graphene.ObjectType):
all_subscription_plans = graphene.relay.ConnectionField(StripePriceConnection)
- active_subscription = graphene.Field(SubscriptionScheduleType)
- all_payment_methods = graphene.relay.ConnectionField(PaymentMethodConnection)
- all_charges = graphene.relay.ConnectionField(ChargeConnection)
- charge = graphene.Field(StripeChargeType, id=graphene.ID())
- payment_intent = graphene.Field(StripePaymentIntentType, id=graphene.ID())
+ active_subscription = graphene.Field(SubscriptionScheduleType, tenant_id=graphene.ID())
+ all_payment_methods = graphene.relay.ConnectionField(PaymentMethodConnection, tenant_id=graphene.ID())
+ all_charges = graphene.relay.ConnectionField(ChargeConnection, tenant_id=graphene.ID())
+ charge = graphene.Field(StripeChargeType, id=graphene.ID(), tenant_id=graphene.ID())
+ payment_intent = graphene.Field(StripePaymentIntentType, id=graphene.ID(), tenant_id=graphene.ID())
@staticmethod
@permission_classes(AnyoneFullAccess)
@@ -335,30 +342,36 @@ def resolve_all_subscription_plans(root, info, **kwargs):
)
@staticmethod
- def resolve_active_subscription(root, info):
- return subscriptions.get_schedule(user=info.context.user)
+ @permission_classes(IsTenantOwnerAccess)
+ def resolve_active_subscription(root, info, **kwargs):
+ return subscriptions.get_schedule(tenant=info.context.tenant)
@staticmethod
+ @permission_classes(IsTenantOwnerAccess)
def resolve_all_payment_methods(root, info, **kwargs):
- return djstripe_models.PaymentMethod.objects.filter(customer__subscriber=info.context.user)
+ return djstripe_models.PaymentMethod.objects.filter(customer__subscriber=info.context.tenant)
@staticmethod
+ @permission_classes(IsTenantOwnerAccess)
def resolve_all_charges(root, info, **kwargs):
- customer, _ = djstripe_models.Customer.get_or_create(info.context.user)
+ customer, _ = djstripe_models.Customer.get_or_create(info.context.tenant)
return customer.charges.filter(status=djstripe_enums.ChargeStatus.succeeded).order_by("-created")
@staticmethod
- def resolve_charge(root, info, id):
+ @permission_classes(IsTenantOwnerAccess)
+ def resolve_charge(root, info, id, **kwargs):
_, pk = from_global_id(id)
- customer, _ = djstripe_models.Customer.get_or_create(info.context.user)
+ customer, _ = djstripe_models.Customer.get_or_create(info.context.tenant)
return customer.charges.get(status=djstripe_enums.ChargeStatus.succeeded, pk=pk)
@staticmethod
- def resolve_payment_intent(root, info, id):
+ @permission_classes(IsTenantOwnerAccess)
+ def resolve_payment_intent(root, info, id, **kwargs):
_, pk = from_global_id(id)
- return djstripe_models.PaymentIntent.objects.get(customer__subscriber=info.context.user, pk=pk)
+ return djstripe_models.PaymentIntent.objects.get(customer__subscriber=info.context.tenant, pk=pk)
+@permission_classes(IsTenantOwnerAccess)
class Mutation(graphene.ObjectType):
change_active_subscription = ChangeActiveSubscriptionMutation.Field()
cancel_active_subscription = CancelActiveSubscriptionMutation.Field()
diff --git a/packages/backend/apps/finances/serializers.py b/packages/backend/apps/finances/serializers.py
index 9f4d51c93..e006b5ed1 100644
--- a/packages/backend/apps/finances/serializers.py
+++ b/packages/backend/apps/finances/serializers.py
@@ -31,7 +31,7 @@ class Meta:
def create(self, validated_data):
request = self.context['request']
- (customer, _) = djstripe_models.Customer.get_or_create(request.user)
+ (customer, _) = djstripe_models.Customer.get_or_create(request.tenant)
amount = int(validated_data['product']) * 100
payment_intent_response = djstripe_models.PaymentIntent._api_create(
amount=amount,
@@ -56,7 +56,7 @@ class Meta:
def create(self, validated_data):
request = self.context['request']
- (customer, _) = djstripe_models.Customer.get_or_create(request.user)
+ (customer, _) = djstripe_models.Customer.get_or_create(request.tenant)
setup_intent_response = djstripe_models.SetupIntent._api_create(
customer=customer.id, payment_method_types=['card'], usage='off_session'
)
@@ -77,7 +77,7 @@ class Meta:
class UpdateDefaultPaymentMethodSerializer(serializers.Serializer):
def update(self, instance, validated_data):
- customer, _ = djstripe_models.Customer.get_or_create(self.context['request'].user)
+ customer, _ = djstripe_models.Customer.get_or_create(self.context['request'].tenant)
customers.set_default_payment_method(customer=customer, payment_method=instance)
return instance
@@ -149,7 +149,7 @@ def get_item(self, obj):
return SubscriptionSchedulePhaseItemSerializer(obj['items'][0]).data
-class UserSubscriptionScheduleSerializer(serializers.ModelSerializer):
+class TenantSubscriptionScheduleSerializer(serializers.ModelSerializer):
subscription = SubscriptionSerializer(source='customer.subscription', read_only=True)
default_payment_method = PaymentMethodSerializer(source='customer.default_payment_method', read_only=True)
phases = serializers.SerializerMethodField()
@@ -229,7 +229,7 @@ class Meta:
fields = ('subscription', 'phases', 'can_activate_trial', 'price', 'default_payment_method')
-class CancelUserActiveSubscriptionSerializer(serializers.ModelSerializer):
+class CancelTenantActiveSubscriptionSerializer(serializers.ModelSerializer):
def validate(self, attrs):
if subscriptions.is_current_schedule_phase_plan(schedule=self.instance, plan_config=constants.FREE_PLAN):
raise serializers.ValidationError(
@@ -306,7 +306,7 @@ def update(self, instance: djstripe_models.PaymentIntent, validated_data):
return instance
-class UserChargeInvoiceItemSerializer(serializers.ModelSerializer):
+class TenantChargeInvoiceItemSerializer(serializers.ModelSerializer):
price = PriceSerializer()
class Meta:
@@ -314,16 +314,16 @@ class Meta:
fields = ('id', 'price')
-class UserChargeInvoiceSerializer(serializers.ModelSerializer):
- items = UserChargeInvoiceItemSerializer(source='invoiceitems', many=True)
+class TenantChargeInvoiceSerializer(serializers.ModelSerializer):
+ items = TenantChargeInvoiceItemSerializer(source='invoiceitems', many=True)
class Meta:
model = djstripe_models.Invoice
fields = ('id', 'billing_reason', 'items')
-class UserChargeSerializer(serializers.ModelSerializer):
- invoice = UserChargeInvoiceSerializer()
+class TenantChargeSerializer(serializers.ModelSerializer):
+ invoice = TenantChargeInvoiceSerializer()
billing_details = serializers.JSONField(read_only=True)
payment_method_details = serializers.JSONField(read_only=True)
diff --git a/packages/backend/apps/finances/services/subscriptions.py b/packages/backend/apps/finances/services/subscriptions.py
index 3e500bb8a..70d6136c5 100644
--- a/packages/backend/apps/finances/services/subscriptions.py
+++ b/packages/backend/apps/finances/services/subscriptions.py
@@ -5,7 +5,7 @@
from ..exceptions import UserOrCustomerNotDefined, SubscriptionAndPriceDefinedTogether, SubscriptionOrPriceNotDefined
-def initialize_user(user):
+def initialize_tenant(tenant):
"""
Primary purpose for separating this code into its own function is the ability to mock it during tests so we utilise
a schedule created by factories instead of relying on stripe-mock response
@@ -14,12 +14,12 @@ def initialize_user(user):
:return:
"""
price = models.Price.objects.get_by_plan(constants.FREE_PLAN)
- create_schedule(user=user, price=price)
+ create_schedule(tenant=tenant, price=price)
-def get_schedule(user=None, customer=None):
- if user:
- customer, _ = djstripe_models.Customer.get_or_create(user)
+def get_schedule(tenant=None, customer=None):
+ if tenant:
+ customer, _ = djstripe_models.Customer.get_or_create(tenant)
if customer is None:
raise UserOrCustomerNotDefined("Either user or customer must be defined")
@@ -27,15 +27,15 @@ def get_schedule(user=None, customer=None):
def create_schedule(
- subscription: djstripe_models.Subscription = None, price: djstripe_models.Price = None, user=None, customer=None
+ subscription: djstripe_models.Subscription = None, price: djstripe_models.Price = None, tenant=None, customer=None
):
if subscription and price:
raise SubscriptionAndPriceDefinedTogether("Subscription and price can't be defined together")
subscription_schedule_stripe_instance = None
if price:
- if user:
- customer, _ = djstripe_models.Customer.get_or_create(user)
+ if tenant:
+ customer, _ = djstripe_models.Customer.get_or_create(tenant)
if customer is None:
raise UserOrCustomerNotDefined("Either user or customer must be defined")
diff --git a/packages/backend/apps/finances/signals.py b/packages/backend/apps/finances/signals.py
index ea8dfc61b..a0596a2f5 100644
--- a/packages/backend/apps/finances/signals.py
+++ b/packages/backend/apps/finances/signals.py
@@ -1,27 +1,25 @@
import logging
-from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from stripe.error import AuthenticationError
from .services import subscriptions
+from apps.multitenancy.models import Tenant
logger = logging.getLogger(__name__)
-User = get_user_model()
-
-@receiver(post_save, sender=User)
+@receiver(post_save, sender=Tenant)
def create_free_plan_subscription(sender, instance, created, **kwargs):
if not settings.STRIPE_ENABLED:
return
if created:
try:
- subscriptions.initialize_user(user=instance)
+ subscriptions.initialize_tenant(tenant=instance)
except AuthenticationError as e:
logger.error(msg=e._message, exc_info=e)
return
diff --git a/packages/backend/apps/finances/tests/factories.py b/packages/backend/apps/finances/tests/factories.py
index b8baa5a7c..8c5213475 100644
--- a/packages/backend/apps/finances/tests/factories.py
+++ b/packages/backend/apps/finances/tests/factories.py
@@ -6,7 +6,7 @@
from django.utils import timezone
from djstripe import models as djstripe_models, enums
-from apps.users.tests import factories as user_factories
+from apps.multitenancy.tests import factories as multitenancy_factories
from .. import models, constants
@@ -19,7 +19,7 @@ class Meta:
livemode = False
currency = 'usd'
tax_exempt = enums.CustomerTaxExempt.none
- subscriber = factory.SubFactory(user_factories.UserFactory)
+ subscriber = factory.SubFactory(multitenancy_factories.TenantFactory)
email = factory.LazyAttribute(lambda obj: obj.subscriber.email)
diff --git a/packages/backend/apps/finances/tests/fixtures.py b/packages/backend/apps/finances/tests/fixtures.py
index 70b0ae8f9..06e1d6680 100644
--- a/packages/backend/apps/finances/tests/fixtures.py
+++ b/packages/backend/apps/finances/tests/fixtures.py
@@ -26,7 +26,7 @@
@pytest.fixture(autouse=True)
def mock_init_user(mocker):
- mocker.patch('apps.finances.services.subscriptions.initialize_user')
+ mocker.patch('apps.finances.services.subscriptions.initialize_tenant')
@pytest.fixture(scope='function', autouse=True)
diff --git a/packages/backend/apps/finances/tests/test_schema.py b/packages/backend/apps/finances/tests/test_schema.py
index fc2ee1f32..4c50e63ca 100644
--- a/packages/backend/apps/finances/tests/test_schema.py
+++ b/packages/backend/apps/finances/tests/test_schema.py
@@ -6,6 +6,8 @@
from apps.finances.tests.utils import stripe_encode
from django.utils import timezone
+from apps.multitenancy.constants import TenantUserRole
+
pytestmark = pytest.mark.django_db
@@ -44,8 +46,8 @@ def test_returns_all_items(self, graphene_client, free_plan_price, monthly_plan_
class TestActiveSubscriptionQuery:
ACTIVE_SUBSCRIPTION_QUERY = '''
- query {
- activeSubscription {
+ query($tenantId: ID) {
+ activeSubscription(tenantId: $tenantId) {
subscription {
pk
status
@@ -114,43 +116,99 @@ def assert_response(self, response, schedule):
},
}
- def test_return_error_for_unauthorized_user(self, graphene_client):
- executed = graphene_client.query(self.ACTIVE_SUBSCRIPTION_QUERY)
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_return_error_for_unauthorized_user(self, graphene_client, tenant):
+ executed = graphene_client.query(
+ self.ACTIVE_SUBSCRIPTION_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_return_error_for_admin_user(
+ self,
+ graphene_client,
+ subscription_schedule_factory,
+ monthly_plan_price,
+ user_factory,
+ tenant_membership_factory,
+ ):
+ subscription_schedule = subscription_schedule_factory(
+ phases=[{'items': [{'price': monthly_plan_price.id}], 'trialing': True}]
+ )
+ customer = subscription_schedule.customer
+ tenant = customer.subscriber
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = graphene_client.query(
+ self.ACTIVE_SUBSCRIPTION_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
assert executed["errors"]
assert executed["errors"][0]["message"] == "permission_denied"
def test_trial_fields_in_response_when_customer_already_activated_trial(
- self, graphene_client, subscription_schedule_factory, monthly_plan_price
+ self,
+ graphene_client,
+ subscription_schedule_factory,
+ monthly_plan_price,
+ user_factory,
+ tenant_membership_factory,
):
subscription_schedule = subscription_schedule_factory(
phases=[{'items': [{'price': monthly_plan_price.id}], 'trialing': True}]
)
customer = subscription_schedule.customer
- graphene_client.force_authenticate(customer.subscriber)
+ tenant = customer.subscriber
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
- executed = graphene_client.query(self.ACTIVE_SUBSCRIPTION_QUERY)
+ executed = graphene_client.query(
+ self.ACTIVE_SUBSCRIPTION_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
assert executed['data']['activeSubscription']['phases'][0]['trialEnd']
assert executed['data']['activeSubscription']['subscription']['trialStart']
assert executed['data']['activeSubscription']['subscription']['trialEnd']
assert not executed['data']['activeSubscription']['canActivateTrial']
- def test_trial_fields_in_response_when_user_never_activated_it(self, graphene_client, subscription_schedule):
- user = subscription_schedule.customer.subscriber
+ def test_trial_fields_in_response_when_user_never_activated_it(
+ self, graphene_client, subscription_schedule, user_factory, tenant_membership_factory
+ ):
+ tenant = subscription_schedule.customer.subscriber
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
- executed = graphene_client.query(self.ACTIVE_SUBSCRIPTION_QUERY)
+ executed = graphene_client.query(
+ self.ACTIVE_SUBSCRIPTION_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
response_phase = executed['data']['activeSubscription']['phases'][0]
assert not response_phase['trialEnd']
assert executed['data']['activeSubscription']['canActivateTrial']
- def test_return_active_subscription_data(self, graphene_client, subscription_schedule):
- user = subscription_schedule.customer.subscriber
+ def test_return_active_subscription_data(
+ self, graphene_client, subscription_schedule, user_factory, tenant_membership_factory
+ ):
+ tenant = subscription_schedule.customer.subscriber
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
- executed = graphene_client.query(self.ACTIVE_SUBSCRIPTION_QUERY)
+ executed = graphene_client.query(
+ self.ACTIVE_SUBSCRIPTION_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
+ print(executed)
self.assert_response(executed['data']['activeSubscription'], subscription_schedule)
@@ -187,11 +245,42 @@ class TestChangeActiveSubscriptionMutation:
}
'''
- def test_change_active_subscription(self, graphene_client, subscription_schedule, monthly_plan_price):
- input_data = {'price': monthly_plan_price.id}
- user = subscription_schedule.customer.subscriber
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_change_active_subscription_by_admin(
+ self, graphene_client, subscription_schedule, monthly_plan_price, user_factory, tenant_membership_factory
+ ):
+ tenant = subscription_schedule.customer.subscriber
+ input_data = {'price': monthly_plan_price.id, "tenantId": to_global_id("TenantType", tenant.pk)}
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+
+ executed = graphene_client.mutate(
+ self.CHANGE_ACTIVE_SUBSCRIPTION_MUTATION,
+ variable_values={'input': input_data},
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_change_active_subscription(
+ self, graphene_client, subscription_schedule, monthly_plan_price, user_factory, tenant_membership_factory
+ ):
+ tenant = subscription_schedule.customer.subscriber
+ input_data = {'price': monthly_plan_price.id, "tenantId": to_global_id("TenantType", tenant.pk)}
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+
executed = graphene_client.mutate(
self.CHANGE_ACTIVE_SUBSCRIPTION_MUTATION,
variable_values={'input': input_data},
@@ -201,15 +290,24 @@ def test_change_active_subscription(self, graphene_client, subscription_schedule
assert "errors" not in executed
def test_change_when_user_has_no_payment_method_but_can_activate_trial(
- self, graphene_client, customer_factory, subscription_schedule_factory, free_plan_price, monthly_plan_price
+ self,
+ graphene_client,
+ customer_factory,
+ subscription_schedule_factory,
+ free_plan_price,
+ monthly_plan_price,
+ user_factory,
+ tenant_membership_factory,
):
- input_data = {'price': monthly_plan_price.id}
customer = customer_factory(default_payment_method=None)
- user = customer.subscriber
+ tenant = customer.subscriber
+ input_data = {'price': monthly_plan_price.id, "tenantId": to_global_id("TenantType", tenant.pk)}
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
subscription_schedule_factory(customer=customer, phases=[{'items': [{'price': free_plan_price.id}]}])
djstripe_models.PaymentMethod.objects.filter(customer=customer).delete()
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.CHANGE_ACTIVE_SUBSCRIPTION_MUTATION,
variable_values={'input': input_data},
@@ -219,17 +317,25 @@ def test_change_when_user_has_no_payment_method_but_can_activate_trial(
assert "errors" not in executed
def test_return_error_on_change_if_customer_has_no_payment_method(
- self, graphene_client, customer_factory, monthly_plan_price, subscription_schedule_factory
+ self,
+ graphene_client,
+ customer_factory,
+ monthly_plan_price,
+ subscription_schedule_factory,
+ user_factory,
+ tenant_membership_factory,
):
- input_data = {'price': monthly_plan_price.id}
customer = customer_factory(default_payment_method=None)
- user = customer.subscriber
+ tenant = customer.subscriber
+ input_data = {'price': monthly_plan_price.id, "tenantId": to_global_id("TenantType", tenant.pk)}
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
subscription_schedule_factory(
customer=customer, phases=[{'items': [{'price': monthly_plan_price.id}], 'trial_completed': True}]
)
djstripe_models.PaymentMethod.objects.filter(customer=customer).delete()
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.CHANGE_ACTIVE_SUBSCRIPTION_MUTATION,
variable_values={'input': input_data},
@@ -254,21 +360,32 @@ class TestCancelActiveSubscriptionMutation:
}
'''
- def test_return_error_for_unauthorized_user(self, graphene_client):
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_return_error_for_unauthorized_user(self, graphene_client, tenant):
executed = graphene_client.mutate(
self.CANCEL_ACTIVE_SUBSCRIPTION_MUTATION,
- variable_values={'input': {}},
+ variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}},
)
assert executed["errors"]
assert executed["errors"][0]["message"] == "permission_denied"
- def test_return_error_if_customer_has_no_paid_subscription(self, graphene_client, subscription_schedule):
- graphene_client.force_authenticate(subscription_schedule.customer.subscriber)
+ def test_return_error_if_customer_has_no_paid_subscription(
+ self, graphene_client, subscription_schedule, user_factory, tenant_membership_factory
+ ):
+ tenant = subscription_schedule.customer.subscriber
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.CANCEL_ACTIVE_SUBSCRIPTION_MUTATION,
- variable_values={'input': {}},
+ variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}},
)
assert len(executed["errors"]) == 1
@@ -278,15 +395,51 @@ def test_return_error_if_customer_has_no_paid_subscription(self, graphene_client
{'message': 'Customer has no paid subscription to cancel', 'code': 'no_paid_subscription'}
]
- def test_cancel_trialing_subscription(self, graphene_client, subscription_schedule_factory, monthly_plan_price):
+ def test_cancel_trialing_subscription_by_admin(
+ self,
+ graphene_client,
+ subscription_schedule_factory,
+ monthly_plan_price,
+ user_factory,
+ tenant_membership_factory,
+ ):
+ subscription_schedule = subscription_schedule_factory(
+ phases=[{'items': [{'price': monthly_plan_price.id}], 'trialing': True}]
+ )
+ tenant = subscription_schedule.customer.subscriber
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+
+ executed = graphene_client.mutate(
+ self.CANCEL_ACTIVE_SUBSCRIPTION_MUTATION,
+ variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}},
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_cancel_trialing_subscription(
+ self,
+ graphene_client,
+ subscription_schedule_factory,
+ monthly_plan_price,
+ user_factory,
+ tenant_membership_factory,
+ ):
subscription_schedule = subscription_schedule_factory(
phases=[{'items': [{'price': monthly_plan_price.id}], 'trialing': True}]
)
- graphene_client.force_authenticate(subscription_schedule.customer.subscriber)
+ tenant = subscription_schedule.customer.subscriber
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.CANCEL_ACTIVE_SUBSCRIPTION_MUTATION,
- variable_values={'input': {}},
+ variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}},
)
assert executed['data']
@@ -295,8 +448,8 @@ def test_cancel_trialing_subscription(self, graphene_client, subscription_schedu
class TestAllPaymentMethodsQuery:
ALL_PAYMENT_METHODS_QUERY = '''
- query {
- allPaymentMethods {
+ query($tenantId: ID) {
+ allPaymentMethods(tenantId: $tenantId) {
edges {
node {
pk
@@ -306,18 +459,52 @@ class TestAllPaymentMethodsQuery:
}
'''
- def test_return_error_for_unauthorized_user(self, graphene_client):
- executed = graphene_client.query(self.ALL_PAYMENT_METHODS_QUERY)
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_return_error_for_unauthorized_user(self, graphene_client, tenant):
+ executed = graphene_client.query(
+ self.ALL_PAYMENT_METHODS_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
assert executed["errors"]
assert executed["errors"][0]["message"] == "permission_denied"
- def test_return_only_customer_payment_methods(self, graphene_client, customer, payment_method_factory):
+ def test_return_only_customer_payment_methods_by_admin(
+ self, graphene_client, customer, payment_method_factory, user_factory, tenant_membership_factory
+ ):
+ payment_method_factory(customer=customer)
+ payment_method_factory()
+ tenant = customer.subscriber
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = graphene_client.query(
+ self.ALL_PAYMENT_METHODS_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_return_only_customer_payment_methods(
+ self, graphene_client, customer, payment_method_factory, user_factory, tenant_membership_factory
+ ):
payment_method = payment_method_factory(customer=customer)
other_customer_payment_method = payment_method_factory()
+ tenant = customer.subscriber
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
- graphene_client.force_authenticate(customer.subscriber)
- executed = graphene_client.query(self.ALL_PAYMENT_METHODS_QUERY)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = graphene_client.query(
+ self.ALL_PAYMENT_METHODS_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
assert executed["data"]
assert executed["data"]["allPaymentMethods"]
@@ -343,21 +530,32 @@ class TestUpdateDefaultPaymentMethodMutation:
}
'''
- def test_return_error_for_unauthorized_user(self, graphene_client):
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_return_error_for_unauthorized_user(self, graphene_client, tenant):
executed = graphene_client.mutate(
self.UPDATE_DEFAULT_PAYMENT_METHOD_MUTATION,
- variable_values={'input': {}},
+ variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}},
)
assert executed["errors"]
assert executed["errors"][0]["message"] == "permission_denied"
- def test_fetch_unknown_payment_method_from_stripe(self, graphene_client, stripe_request, payment_method_factory):
+ def test_fetch_unknown_payment_method_from_stripe(
+ self, graphene_client, stripe_request, payment_method_factory, user_factory, tenant_membership_factory
+ ):
other_users_pm = payment_method_factory()
payment_method = payment_method_factory()
- input_data = {"id": other_users_pm.id}
+ tenant = payment_method.customer.subscriber
+ input_data = {"id": other_users_pm.id, "tenantId": to_global_id("TenantType", tenant.pk)}
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
- graphene_client.force_authenticate(payment_method.customer.subscriber)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.UPDATE_DEFAULT_PAYMENT_METHOD_MUTATION,
variable_values={'input': input_data},
@@ -369,11 +567,36 @@ def test_fetch_unknown_payment_method_from_stripe(self, graphene_client, stripe_
'get', calleee.EndsWith(f'/payment_methods/{other_users_pm.id}'), calleee.Any(), None
)
- def test_set_default_payment_method(self, graphene_client, payment_method_factory, customer, stripe_request):
+ def test_set_default_payment_method_by_admin(
+ self, graphene_client, payment_method_factory, customer, user_factory, tenant_membership_factory
+ ):
payment_method = payment_method_factory(customer=customer)
- input_data = {"id": payment_method.id}
+ tenant = payment_method.customer.subscriber
+ input_data = {"id": payment_method.id, "tenantId": to_global_id("TenantType", tenant.pk)}
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
- graphene_client.force_authenticate(payment_method.customer.subscriber)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = graphene_client.mutate(
+ self.UPDATE_DEFAULT_PAYMENT_METHOD_MUTATION,
+ variable_values={'input': input_data},
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_set_default_payment_method(
+ self, graphene_client, payment_method_factory, customer, stripe_request, user_factory, tenant_membership_factory
+ ):
+ payment_method = payment_method_factory(customer=customer)
+ tenant = payment_method.customer.subscriber
+ input_data = {"id": payment_method.id, "tenantId": to_global_id("TenantType", tenant.pk)}
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.UPDATE_DEFAULT_PAYMENT_METHOD_MUTATION,
variable_values={'input': input_data},
@@ -401,12 +624,23 @@ class TestDeletePaymentMethodMutation:
}
'''
- def test_return_error_for_other_users_payment_method(self, graphene_client, stripe_request, payment_method_factory):
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_return_error_for_other_users_payment_method(
+ self, graphene_client, stripe_request, payment_method_factory, user_factory, tenant_membership_factory
+ ):
other_users_pm = payment_method_factory()
payment_method = payment_method_factory()
- input_data = {"id": other_users_pm.id}
+ tenant = payment_method.customer.subscriber
+ input_data = {"id": other_users_pm.id, "tenantId": to_global_id("TenantType", tenant.pk)}
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
- graphene_client.force_authenticate(payment_method.customer.subscriber)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.DELETE_PAYMENT_METHOD_MUTATION,
variable_values={'input': input_data},
@@ -418,12 +652,36 @@ def test_return_error_for_other_users_payment_method(self, graphene_client, stri
'get', calleee.EndsWith(f'/payment_methods/{other_users_pm.id}'), calleee.Any(), None
)
- def test_detach_payment_method(self, graphene_client, stripe_request, payment_method):
+ def test_detach_payment_method_by_admin(
+ self, graphene_client, payment_method, user_factory, tenant_membership_factory
+ ):
+ tenant = payment_method.customer.subscriber
+ input_data = {"id": payment_method.id, "tenantId": to_global_id("TenantType", tenant.pk)}
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = graphene_client.mutate(
+ self.DELETE_PAYMENT_METHOD_MUTATION,
+ variable_values={'input': input_data},
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_detach_payment_method(
+ self, graphene_client, stripe_request, payment_method, user_factory, tenant_membership_factory
+ ):
customer = payment_method.customer
payment_method_global_id = to_global_id('StripePaymentMethodType', str(payment_method.djstripe_id))
- input_data = {"id": payment_method.id}
+ tenant = payment_method.customer.subscriber
+ input_data = {"id": payment_method.id, "tenantId": to_global_id("TenantType", tenant.pk)}
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
- graphene_client.force_authenticate(customer.subscriber)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.DELETE_PAYMENT_METHOD_MUTATION,
variable_values={'input': input_data},
@@ -440,15 +698,18 @@ def test_detach_payment_method(self, graphene_client, stripe_request, payment_me
)
def test_set_default_payment_method_to_next_one(
- self, graphene_client, stripe_request, customer, payment_method_factory
+ self, graphene_client, stripe_request, customer, payment_method_factory, user_factory, tenant_membership_factory
):
payment_method = payment_method_factory(customer=customer)
- input_data = {"id": payment_method.id}
customer.default_payment_method = payment_method
customer.save()
other_payment_method = payment_method_factory(customer=customer)
+ tenant = payment_method.customer.subscriber
+ input_data = {"id": payment_method.id, "tenantId": to_global_id("TenantType", tenant.pk)}
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
- graphene_client.force_authenticate(customer.subscriber)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.DELETE_PAYMENT_METHOD_MUTATION,
variable_values={'input': input_data},
@@ -473,8 +734,8 @@ def test_set_default_payment_method_to_next_one(
class TestAllChargesQuery:
ALL_CHARGES_QUERY = '''
- query {
- allCharges {
+ query($tenantId: ID) {
+ allCharges(tenantId: $tenantId) {
edges {
node {
pk
@@ -484,18 +745,50 @@ class TestAllChargesQuery:
}
'''
- def test_return_error_for_unauthorized_user(self, graphene_client):
- executed = graphene_client.query(self.ALL_CHARGES_QUERY)
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_return_error_for_unauthorized_user(self, graphene_client, tenant):
+ executed = graphene_client.query(
+ self.ALL_CHARGES_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_return_only_customer_charges_by_admin(
+ self, graphene_client, customer, user_factory, tenant_membership_factory
+ ):
+ tenant = customer.subscriber
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = graphene_client.query(
+ self.ALL_CHARGES_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
assert executed["errors"]
assert executed["errors"][0]["message"] == "permission_denied"
- def test_return_only_customer_charges(self, graphene_client, customer, charge_factory):
+ def test_return_only_customer_charges(
+ self, graphene_client, customer, charge_factory, user_factory, tenant_membership_factory
+ ):
other_customer_charge = charge_factory()
regular_charge = charge_factory(customer=customer)
+ tenant = customer.subscriber
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
- graphene_client.force_authenticate(customer.subscriber)
- executed = graphene_client.query(self.ALL_CHARGES_QUERY)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = graphene_client.query(
+ self.ALL_CHARGES_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
assert executed["data"]
assert executed["data"]["allCharges"]
@@ -506,13 +799,20 @@ def test_return_only_customer_charges(self, graphene_client, customer, charge_fa
assert regular_charge.id in charge_ids
assert other_customer_charge.id not in charge_ids
- def test_return_charges_ordered_by_creation_date_descending(self, graphene_client, customer, charge_factory):
+ def test_return_charges_ordered_by_creation_date_descending(
+ self, graphene_client, customer, charge_factory, user_factory, tenant_membership_factory
+ ):
old_charge = charge_factory(customer=customer, created=timezone.now() - timedelta(days=1))
oldest_charge = charge_factory(customer=customer, created=timezone.now() - timedelta(days=2))
new_charge = charge_factory(customer=customer, created=timezone.now())
+ tenant = customer.subscriber
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
- graphene_client.force_authenticate(customer.subscriber)
- executed = graphene_client.query(self.ALL_CHARGES_QUERY)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = graphene_client.query(
+ self.ALL_CHARGES_QUERY, variable_values={"tenantId": to_global_id("TenantType", tenant.id)}
+ )
assert executed["data"]
assert executed["data"]["allCharges"]
@@ -525,8 +825,8 @@ def test_return_charges_ordered_by_creation_date_descending(self, graphene_clien
class TestChargeQuery:
CHARGE_QUERY = '''
- query getCharge($id: ID!) {
- charge(id: $id) {
+ query getCharge($id: ID!, $tenantId: ID) {
+ charge(id: $id, tenantId: $tenantId) {
id
amount
billingDetails
@@ -543,21 +843,51 @@ class TestChargeQuery:
}
'''
- def test_return_error_for_unauthorized_user(self, graphene_client, customer, charge_factory):
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_return_error_for_unauthorized_user(self, graphene_client, customer, charge_factory, tenant):
charge = charge_factory(customer=customer)
- variable_values = {"id": to_global_id('StripeChargeType', str(charge.pk))}
+ variable_values = {
+ "id": to_global_id('StripeChargeType', str(charge.pk)),
+ "tenantId": to_global_id("TenantType", tenant.id),
+ }
executed = graphene_client.query(self.CHARGE_QUERY, variable_values=variable_values)
assert executed["errors"]
assert executed["errors"][0]["message"] == "permission_denied"
- def test_return_charge(self, graphene_client, customer, charge_factory):
+ def test_return_charge_by_admin(
+ self, graphene_client, customer, charge_factory, user_factory, tenant_membership_factory
+ ):
charge = charge_factory(customer=customer)
charge_global_id = to_global_id('StripeChargeType', str(charge.pk))
- variable_values = {"id": charge_global_id}
+ tenant = customer.subscriber
+ variable_values = {"id": charge_global_id, "tenantId": to_global_id("TenantType", tenant.id)}
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
- graphene_client.force_authenticate(customer.subscriber)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = graphene_client.query(self.CHARGE_QUERY, variable_values=variable_values)
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_return_charge(self, graphene_client, customer, charge_factory, user_factory, tenant_membership_factory):
+ charge = charge_factory(customer=customer)
+ charge_global_id = to_global_id('StripeChargeType', str(charge.pk))
+ tenant = customer.subscriber
+ variable_values = {"id": charge_global_id, "tenantId": to_global_id("TenantType", tenant.id)}
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.query(self.CHARGE_QUERY, variable_values=variable_values)
assert executed["data"]
@@ -590,8 +920,8 @@ def test_return_charge(self, graphene_client, customer, charge_factory):
class TestPaymentIntentQuery:
PAYMENT_INTENT_QUERY = '''
- query getPaymentIntent($id: ID!) {
- paymentIntent(id: $id) {
+ query getPaymentIntent($id: ID!, $tenantId: ID) {
+ paymentIntent(id: $id, tenantId: $tenantId) {
id
amount
currency
@@ -600,32 +930,69 @@ class TestPaymentIntentQuery:
}
'''
- def test_return_error_for_unauthorized_user(self, graphene_client, customer, payment_intent_factory):
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_return_error_for_unauthorized_user(self, graphene_client, customer, payment_intent_factory, tenant):
payment_intent = payment_intent_factory(customer=customer)
- variable_values = {"id": to_global_id('StripePaymentIntentType', str(payment_intent.pk))}
+ variable_values = {
+ "id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)),
+ "tenantId": to_global_id("TenantType", tenant.id),
+ }
executed = graphene_client.query(self.PAYMENT_INTENT_QUERY, variable_values=variable_values)
assert executed["errors"]
assert executed["errors"][0]["message"] == "permission_denied"
- def test_return_error_if_not_users_payment_intent(self, graphene_client, customer, payment_intent_factory):
+ def test_return_error_if_not_users_payment_intent(
+ self, graphene_client, customer, payment_intent_factory, user_factory, tenant_membership_factory
+ ):
payment_intent = payment_intent_factory()
payment_intent_global_id = to_global_id('StripePaymentIntentType', str(payment_intent.pk))
- variable_values = {"id": payment_intent_global_id}
+ tenant = customer.subscriber
+ variable_values = {"id": payment_intent_global_id, "tenantId": to_global_id("TenantType", tenant.id)}
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
- graphene_client.force_authenticate(customer.subscriber)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.query(self.PAYMENT_INTENT_QUERY, variable_values=variable_values)
assert executed["errors"]
assert executed["errors"][0]["message"] == "PaymentIntent matching query does not exist."
- def test_return_payment_intent(self, graphene_client, customer, payment_intent_factory):
+ def test_return_payment_intent_by_admin(
+ self, graphene_client, customer, payment_intent_factory, user_factory, tenant_membership_factory
+ ):
+ payment_intent = payment_intent_factory(customer=customer)
+ payment_intent_global_id = to_global_id('StripePaymentIntentType', str(payment_intent.pk))
+ tenant = customer.subscriber
+ variable_values = {"id": payment_intent_global_id, "tenantId": to_global_id("TenantType", tenant.id)}
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = graphene_client.query(self.PAYMENT_INTENT_QUERY, variable_values=variable_values)
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_return_payment_intent(
+ self, graphene_client, customer, payment_intent_factory, user_factory, tenant_membership_factory
+ ):
payment_intent = payment_intent_factory(customer=customer)
payment_intent_global_id = to_global_id('StripePaymentIntentType', str(payment_intent.pk))
- variable_values = {"id": payment_intent_global_id}
+ tenant = customer.subscriber
+ variable_values = {"id": payment_intent_global_id, "tenantId": to_global_id("TenantType", tenant.id)}
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
- graphene_client.force_authenticate(customer.subscriber)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.query(self.PAYMENT_INTENT_QUERY, variable_values=variable_values)
assert executed["data"]
@@ -652,8 +1019,14 @@ class TestCreatePaymentIntentMutation:
}
'''
- def test_return_error_for_unauthorized_user(self, graphene_client):
- input_data = {"product": "A"}
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_return_error_for_unauthorized_user(self, graphene_client, tenant):
+ input_data = {"product": "A", "tenantId": to_global_id("TenantType", tenant.pk)}
executed = graphene_client.mutate(
self.CREATE_PAYMENT_INTENT_MUTATION,
@@ -663,8 +1036,15 @@ def test_return_error_for_unauthorized_user(self, graphene_client):
assert executed["errors"]
assert executed["errors"][0]["message"] == "permission_denied"
- def test_return_error_if_product_is_not_passed(self, graphene_client, user):
- input_data = {}
+ def test_return_error_if_product_is_not_passed(
+ self, graphene_client, user_factory, tenant, tenant_membership_factory
+ ):
+ input_data = {"tenantId": to_global_id("TenantType", tenant.pk)}
+
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
graphene_client.force_authenticate(user)
executed = graphene_client.mutate(
@@ -673,14 +1053,17 @@ def test_return_error_if_product_is_not_passed(self, graphene_client, user):
)
assert executed["errors"]
- assert executed["errors"][0]["message"] == (
- "Variable '$input' got invalid value {}; Field 'product' of required type 'String!' was not provided."
- )
+ assert "Field 'product' of required type 'String!' was not provided." in executed["errors"][0]["message"]
- def test_return_error_if_product_does_not_exist(self, graphene_client, user):
- input_data = {"product": "A"}
+ def test_return_error_if_product_does_not_exist(
+ self, graphene_client, user_factory, tenant, tenant_membership_factory
+ ):
+ input_data = {"product": "A", "tenantId": to_global_id("TenantType", tenant.pk)}
+
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.CREATE_PAYMENT_INTENT_MUTATION,
variable_values={'input': input_data},
@@ -691,10 +1074,30 @@ def test_return_error_if_product_does_not_exist(self, graphene_client, user):
assert error["path"] == ["createPaymentIntent"]
assert error["extensions"]["product"] == [{'message': '"A" is not a valid choice.', 'code': 'invalid_choice'}]
- def test_creates_payment_intent(self, graphene_client, user):
- input_data = {"product": "5"}
+ def test_creates_payment_intent_by_admin(self, graphene_client, user_factory, tenant, tenant_membership_factory):
+ input_data = {"product": "5", "tenantId": to_global_id("TenantType", tenant.pk)}
+
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = graphene_client.mutate(
+ self.CREATE_PAYMENT_INTENT_MUTATION,
+ variable_values={'input': input_data},
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_creates_payment_intent(self, graphene_client, user_factory, tenant, tenant_membership_factory):
+ input_data = {"product": "5", "tenantId": to_global_id("TenantType", tenant.pk)}
+
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.CREATE_PAYMENT_INTENT_MUTATION,
variable_values={'input': input_data},
@@ -726,9 +1129,19 @@ class TestUpdatePaymentIntentMutation:
}
'''
- def test_return_error_for_unauthorized_user(self, graphene_client, payment_intent_factory):
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_return_error_for_unauthorized_user(self, graphene_client, payment_intent_factory, tenant):
payment_intent = payment_intent_factory(amount=50)
- input_data = {"id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)), "product": "10"}
+ input_data = {
+ "id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)),
+ "product": "10",
+ "tenantId": to_global_id("TenantType", tenant.pk),
+ }
executed = graphene_client.mutate(
self.UPDATE_PAYMENT_INTENT_MUTATION,
@@ -738,11 +1151,20 @@ def test_return_error_for_unauthorized_user(self, graphene_client, payment_inten
assert executed["errors"]
assert executed["errors"][0]["message"] == "permission_denied"
- def test_return_error_if_product_belongs_to_other_user(self, graphene_client, payment_intent_factory, customer):
+ def test_return_error_if_product_belongs_to_other_user(
+ self, graphene_client, payment_intent_factory, customer, user_factory, tenant_membership_factory
+ ):
payment_intent = payment_intent_factory(amount=50)
- input_data = {"id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)), "product": "10"}
+ tenant = customer.subscriber
+ input_data = {
+ "id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)),
+ "product": "10",
+ "tenantId": to_global_id("TenantType", tenant.pk),
+ }
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
- graphene_client.force_authenticate(customer.subscriber)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.UPDATE_PAYMENT_INTENT_MUTATION,
variable_values={'input': input_data},
@@ -751,11 +1173,44 @@ def test_return_error_if_product_belongs_to_other_user(self, graphene_client, pa
assert executed["errors"]
assert executed["errors"][0]["message"] == "No PaymentIntent matches the given query."
- def test_update_payment_intent_amount(self, graphene_client, payment_intent_factory, customer):
+ def test_update_payment_intent_amount_by_admin(
+ self, graphene_client, payment_intent_factory, customer, user_factory, tenant_membership_factory
+ ):
payment_intent = payment_intent_factory(amount=50, customer=customer)
- input_data = {"id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)), "product": "10"}
+ tenant = customer.subscriber
+ input_data = {
+ "id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)),
+ "product": "10",
+ "tenantId": to_global_id("TenantType", tenant.pk),
+ }
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
- graphene_client.force_authenticate(customer.subscriber)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = graphene_client.mutate(
+ self.UPDATE_PAYMENT_INTENT_MUTATION,
+ variable_values={'input': input_data},
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_update_payment_intent_amount(
+ self, graphene_client, payment_intent_factory, customer, user_factory, tenant_membership_factory
+ ):
+ payment_intent = payment_intent_factory(amount=50, customer=customer)
+ tenant = customer.subscriber
+ input_data = {
+ "id": to_global_id('StripePaymentIntentType', str(payment_intent.pk)),
+ "product": "10",
+ "tenantId": to_global_id("TenantType", tenant.pk),
+ }
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.UPDATE_PAYMENT_INTENT_MUTATION,
variable_values={'input': input_data},
@@ -781,20 +1236,44 @@ class TestCreateSetupIntentMutation:
}
'''
- def test_return_error_for_unauthorized_user(self, graphene_client):
+ @staticmethod
+ def _get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory, role=TenantUserRole.OWNER):
+ user = user_factory()
+ tenant_membership_factory(tenant=tenant, role=role, user=user)
+ return user
+
+ def test_return_error_for_unauthorized_user(self, graphene_client, tenant):
executed = graphene_client.mutate(
self.CREATE_PAYMENT_INTENT_MUTATION,
- variable_values={'input': {}},
+ variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}},
)
assert executed["errors"]
assert executed["errors"][0]["message"] == "permission_denied"
- def test_creates_payment_intent(self, graphene_client, user):
+ def test_creates_payment_intent_by_admin(self, graphene_client, user_factory, tenant, tenant_membership_factory):
+ user = self._get_user_from_customer_tenant(
+ tenant, user_factory, tenant_membership_factory, TenantUserRole.ADMIN
+ )
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = graphene_client.mutate(
+ self.CREATE_PAYMENT_INTENT_MUTATION,
+ variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}},
+ )
+
+ assert executed["errors"]
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_creates_payment_intent(self, graphene_client, user_factory, tenant, tenant_membership_factory):
+ user = self._get_user_from_customer_tenant(tenant, user_factory, tenant_membership_factory)
+
graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
executed = graphene_client.mutate(
self.CREATE_PAYMENT_INTENT_MUTATION,
- variable_values={'input': {}},
+ variable_values={'input': {"tenantId": to_global_id("TenantType", tenant.pk)}},
)
assert executed['data']['createSetupIntent']
diff --git a/packages/backend/apps/multitenancy/__init__.py b/packages/backend/apps/multitenancy/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/backend/apps/multitenancy/admin.py b/packages/backend/apps/multitenancy/admin.py
new file mode 100644
index 000000000..70cb87bc6
--- /dev/null
+++ b/packages/backend/apps/multitenancy/admin.py
@@ -0,0 +1,13 @@
+from django.contrib import admin
+
+from . import models
+
+
+@admin.register(models.Tenant)
+class TenantAdmin(admin.ModelAdmin):
+ list_display = ("id", "name", "type")
+
+
+@admin.register(models.TenantMembership)
+class TenantMembershipAdmin(admin.ModelAdmin):
+ list_display = ("id", "role", "user", "invitee_email_address", "tenant", "is_accepted")
diff --git a/packages/backend/apps/multitenancy/apps.py b/packages/backend/apps/multitenancy/apps.py
new file mode 100644
index 000000000..c832d5184
--- /dev/null
+++ b/packages/backend/apps/multitenancy/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class MultitenancyConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'apps.multitenancy'
diff --git a/packages/backend/apps/multitenancy/constants.py b/packages/backend/apps/multitenancy/constants.py
new file mode 100644
index 000000000..1fcf714d6
--- /dev/null
+++ b/packages/backend/apps/multitenancy/constants.py
@@ -0,0 +1,32 @@
+from enum import Enum
+from django.db import models
+
+
+class TenantType(models.TextChoices):
+ """
+ DEFAULT is a tenant type created during user sign-up.
+ It serves as the default tenant for them, ensuring that they always have at least one.
+ ORGANIZATION is a tenant type for tenants created manually by the user for the purpose of inviting other members.
+ """
+
+ DEFAULT = "default", "Default"
+ ORGANIZATION = "organization", "Organization"
+
+
+class TenantUserRole(models.TextChoices):
+ """
+ Predefined tenant user roles:
+ - OWNER
+ - ADMIN
+ - MEMBER
+ """
+
+ OWNER = "OWNER", "Owner"
+ ADMIN = "ADMIN", "Administrator"
+ MEMBER = "MEMBER", "Member"
+
+
+class Notification(Enum):
+ TENANT_INVITATION_CREATED = "TENANT_INVITATION_CREATED"
+ TENANT_INVITATION_ACCEPTED = "TENANT_INVITATION_ACCEPTED"
+ TENANT_INVITATION_DECLINED = "TENANT_INVITATION_DECLINED"
diff --git a/packages/backend/apps/multitenancy/email_serializers.py b/packages/backend/apps/multitenancy/email_serializers.py
new file mode 100644
index 000000000..0f0491d2f
--- /dev/null
+++ b/packages/backend/apps/multitenancy/email_serializers.py
@@ -0,0 +1,6 @@
+from rest_framework import serializers
+
+
+class TenantInvitationEmailSerializer(serializers.Serializer):
+ token = serializers.CharField()
+ tenant_membership_id = serializers.CharField()
diff --git a/packages/backend/apps/multitenancy/managers.py b/packages/backend/apps/multitenancy/managers.py
new file mode 100644
index 000000000..734b6ed65
--- /dev/null
+++ b/packages/backend/apps/multitenancy/managers.py
@@ -0,0 +1,58 @@
+from django.db import models
+
+from .constants import TenantType, TenantUserRole
+
+
+class TenantManager(models.Manager):
+ def get_or_create_user_default_tenant(self, user):
+ """
+ Description:
+ Retrieves or creates a default tenant for a given user, ensuring that there is always at least one tenant
+ instance associated with them.
+
+ Parameters:
+ - user (User): The user for whom the tenant is retrieved or created.
+
+ Returns:
+ Tenant: The associated or newly created tenant instance of SIGN_UP type.
+ """
+ default_tenant = self.filter(creator=user, type=TenantType.DEFAULT).order_by('created_at').first()
+ if default_tenant:
+ return default_tenant, False
+
+ new_tenant = self.create(creator=user, type=TenantType.DEFAULT, name=str(user))
+ new_tenant.members.add(
+ user, through_defaults={"tenant": new_tenant, "role": TenantUserRole.OWNER, "is_accepted": True}
+ )
+
+ return new_tenant, True
+
+
+class TenantMembershipManager(models.Manager):
+ def get_queryset(self):
+ """
+ Overrides the default get_queryset function to exclude invitations from the queryset.
+ """
+ return super().get_queryset().filter(is_accepted=True)
+
+ def get_not_accepted(self):
+ """
+ Retrieves not accepted tenant invitations.
+ """
+ return super().get_queryset().filter(is_accepted=False)
+
+ def get_all(self, **kwargs):
+ """
+ Retrieves all tenants memberships.
+ """
+ return super().get_queryset(**kwargs)
+
+ def associate_invitations_with_user(self, email, user):
+ """
+ Associates not accepted tenant invitations with a user.
+
+ This method finds not accepted tenant invitations with the specified email address
+ and associates them with the provided user.
+ """
+ invitations = self.get_not_accepted().filter(invitee_email_address=email)
+ invitations.update(user=user)
diff --git a/packages/backend/apps/multitenancy/middleware.py b/packages/backend/apps/multitenancy/middleware.py
new file mode 100644
index 000000000..f654e6b88
--- /dev/null
+++ b/packages/backend/apps/multitenancy/middleware.py
@@ -0,0 +1,82 @@
+from graphql_relay import from_global_id
+from django.utils.functional import SimpleLazyObject
+from .models import Tenant, TenantMembership
+
+
+def get_current_tenant(tenant_id):
+ """
+ Retrieve the current tenant based on the provided tenant ID.
+
+ Args:
+ tenant_id (str): The global ID of the tenant.
+
+ Returns:
+ Tenant or None: The retrieved tenant or None if not found.
+ """
+ try:
+ return Tenant.objects.get(pk=tenant_id)
+ except (Tenant.DoesNotExist, TypeError):
+ return None
+
+
+def get_current_user_role(tenant, user):
+ """
+ Retrieve the user role within the specified tenant.
+
+ Args:
+ tenant (Tenant): The current tenant.
+ user (User): The user for whom the role is to be retrieved.
+
+ Returns:
+ str or None: The user role or None if not found or invalid conditions.
+ """
+ if user and user.is_authenticated and tenant:
+ try:
+ membership = TenantMembership.objects.get(user=user, tenant=tenant)
+ return membership.role
+ except TenantMembership.DoesNotExist:
+ return None
+
+ return None
+
+
+class TenantUserRoleMiddleware(object):
+ """
+ Middleware for resolving the current tenant and user role lazily.
+
+ This middleware is responsible for setting the "tenant" and "user_role" attributes in the GraphQL execution context.
+ The actual retrieval of the current tenant and user role is deferred until the values are accessed. Lazy loading is
+ employed to optimize performance by loading these values only when necessary.
+ """
+
+ @staticmethod
+ def _get_tenant_id_from_arguments(args):
+ """
+ Extract the tenant ID from GraphQL arguments.
+
+ Args:
+ args (dict): GraphQL arguments.
+
+ Returns:
+ str or None: The extracted tenant ID or None if not found.
+ """
+ request_input = args.get("input")
+
+ tenant_id = (
+ request_input.get("tenant_id") or request_input.get("id")
+ if request_input
+ else args.get("tenant_id") or args.get("id")
+ )
+
+ if tenant_id:
+ id_type, pk = from_global_id(tenant_id)
+ if id_type == "TenantType":
+ return pk
+ return None
+
+ def resolve(self, next, root, info, **args):
+ if not hasattr(info.context, "tenant_id"):
+ info.context.tenant_id = self._get_tenant_id_from_arguments(args)
+ info.context.tenant = SimpleLazyObject(lambda: get_current_tenant(info.context.tenant_id))
+ info.context.user_role = SimpleLazyObject(lambda: get_current_user_role(info.context.tenant, info.context.user))
+ return next(root, info, **args)
diff --git a/packages/backend/apps/multitenancy/migrations/0001_initial.py b/packages/backend/apps/multitenancy/migrations/0001_initial.py
new file mode 100644
index 000000000..2685e615a
--- /dev/null
+++ b/packages/backend/apps/multitenancy/migrations/0001_initial.py
@@ -0,0 +1,84 @@
+# Generated by Django 4.2 on 2024-02-14 11:49
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import hashid_field.field
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Tenant',
+ fields=[
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ (
+ 'id',
+ hashid_field.field.HashidAutoField(
+ alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890',
+ min_length=7,
+ prefix='',
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('slug', models.SlugField(max_length=100, unique=True)),
+ ('type', models.CharField(choices=[('default', 'Default'), ('organization', 'Organization')])),
+ (
+ 'creator',
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+ ),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.CreateModel(
+ name='TenantMembership',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ (
+ 'role',
+ models.CharField(
+ choices=[('owner', 'Owner'), ('admin', 'Administrator'), ('member', 'Member')], default='owner'
+ ),
+ ),
+ (
+ 'tenant',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='user_memberships',
+ to='multitenancy.tenant',
+ ),
+ ),
+ (
+ 'user',
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='tenant_memberships',
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ 'unique_together': {('user', 'tenant')},
+ },
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='members',
+ field=models.ManyToManyField(
+ blank=True, related_name='tenants', through='multitenancy.TenantMembership', to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ ]
diff --git a/packages/backend/apps/multitenancy/migrations/0002_alter_tenant_name.py b/packages/backend/apps/multitenancy/migrations/0002_alter_tenant_name.py
new file mode 100644
index 000000000..309b30344
--- /dev/null
+++ b/packages/backend/apps/multitenancy/migrations/0002_alter_tenant_name.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2 on 2024-02-16 08:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('multitenancy', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='tenant',
+ name='name',
+ field=models.CharField(max_length=100),
+ ),
+ ]
diff --git a/packages/backend/apps/multitenancy/migrations/0003_alter_tenantmembership_unique_together_and_more.py b/packages/backend/apps/multitenancy/migrations/0003_alter_tenantmembership_unique_together_and_more.py
new file mode 100644
index 000000000..a14a535be
--- /dev/null
+++ b/packages/backend/apps/multitenancy/migrations/0003_alter_tenantmembership_unique_together_and_more.py
@@ -0,0 +1,74 @@
+# Generated by Django 4.2 on 2024-03-06 12:34
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import hashid_field.field
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('multitenancy', '0002_alter_tenant_name'),
+ ]
+
+ operations = [
+ migrations.AlterUniqueTogether(
+ name='tenantmembership',
+ unique_together=set(),
+ ),
+ migrations.AddField(
+ model_name='tenantmembership',
+ name='invitation_accepted_at',
+ field=models.DateTimeField(null=True),
+ ),
+ migrations.AddField(
+ model_name='tenantmembership',
+ name='invitee_email_address',
+ field=models.EmailField(
+ db_collation='case_insensitive', default='', max_length=255, verbose_name='invitee email address'
+ ),
+ ),
+ migrations.AddField(
+ model_name='tenantmembership',
+ name='is_accepted',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AlterField(
+ model_name='tenantmembership',
+ name='id',
+ field=hashid_field.field.HashidAutoField(
+ alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890',
+ min_length=7,
+ prefix='',
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ migrations.AlterField(
+ model_name='tenantmembership',
+ name='user',
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name='tenant_memberships',
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name='tenantmembership',
+ constraint=models.UniqueConstraint(
+ condition=models.Q(('user__isnull', False)),
+ fields=('user', 'tenant'),
+ name='unique_non_null_user_and_tenant',
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name='tenantmembership',
+ constraint=models.UniqueConstraint(
+ condition=models.Q(('invitee_email_address__exact', ''), _negated=True),
+ fields=('invitee_email_address', 'tenant'),
+ name='unique_non_null_user_and_invitee_email_address',
+ ),
+ ),
+ ]
diff --git a/packages/backend/apps/multitenancy/migrations/0004_auto_20240318_1003.py b/packages/backend/apps/multitenancy/migrations/0004_auto_20240318_1003.py
new file mode 100644
index 000000000..600f9c5cf
--- /dev/null
+++ b/packages/backend/apps/multitenancy/migrations/0004_auto_20240318_1003.py
@@ -0,0 +1,79 @@
+from django.db import migrations
+from django.utils.text import slugify
+from django.apps.registry import apps as global_apps
+
+from ..constants import TenantType, TenantUserRole
+
+
+def move_user_to_tenant(apps, schema_editor):
+ Customer = apps.get_model('djstripe', 'Customer')
+ Tenant = apps.get_model('multitenancy', 'Tenant')
+ User = apps.get_model('users', 'User')
+ connection = schema_editor.connection
+
+ for customer in Customer.objects.all():
+ default_tenant = Tenant.objects.filter(type=TenantType.DEFAULT, creator_id=customer.subscriber_id).first()
+ if not default_tenant:
+ user = User.objects.filter(pk=customer.subscriber_id).first()
+ default_tenant = Tenant.objects.create(
+ creator_id=customer.subscriber_id, type=TenantType.DEFAULT, name=user.email, slug=slugify(user.email))
+ default_tenant.members.add(
+ customer.subscriber_id,
+ through_defaults={"tenant": default_tenant, "role": TenantUserRole.OWNER, "is_accepted": True}
+ )
+ tenant_id = default_tenant.id.id
+ customer_id = customer.id
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "UPDATE djstripe_customer SET new_subscriber_id = %s WHERE id = %s",
+ [tenant_id, customer_id],
+ )
+
+
+def revert_move_user_to_tenant(apps, schema_editor):
+ Customer = global_apps.get_model('djstripe', 'Customer')
+ Tenant = apps.get_model('multitenancy', 'Tenant')
+ connection = schema_editor.connection
+
+ for customer in Customer.objects.all():
+ connected_tenant = Tenant.objects.filter(pk=customer.subscriber_id).first()
+ if connected_tenant and connected_tenant.type == TenantType.DEFAULT:
+ tenant_id = connected_tenant.creator_id.id
+ customer_id = customer.id
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "UPDATE djstripe_customer SET new_subscriber_id = %s WHERE id = %s",
+ [tenant_id, customer_id],
+ )
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('multitenancy', '0003_alter_tenantmembership_unique_together_and_more'),
+ ('djstripe', '0012_2_8'),
+ ]
+
+ operations = [
+ # SQL for adding the new_subscriber_id column with foreign key definition
+ migrations.RunSQL(
+ 'ALTER TABLE djstripe_customer ADD COLUMN new_subscriber_id integer REFERENCES multitenancy_tenant(id);',
+ reverse_sql=[
+ 'ALTER TABLE djstripe_customer DROP COLUMN subscriber_id;',
+ 'ALTER TABLE djstripe_customer RENAME COLUMN new_subscriber_id TO subscriber_id;'
+ ],
+ ),
+
+ # Python function to move user data to tenant
+ migrations.RunPython(move_user_to_tenant, revert_move_user_to_tenant),
+
+ # SQL for removing the old subscriber_id column and renaming the new_subscriber_id column back to subscriber_id
+ migrations.RunSQL(
+ [
+ 'ALTER TABLE djstripe_customer DROP COLUMN subscriber_id;',
+ 'ALTER TABLE djstripe_customer RENAME COLUMN new_subscriber_id TO subscriber_id;'
+ ],
+ reverse_sql=[
+ 'ALTER TABLE djstripe_customer ADD COLUMN new_subscriber_id integer REFERENCES users_user(id);',
+ ]
+ ),
+ ]
diff --git a/packages/backend/apps/multitenancy/migrations/0005_tenant_billing_email.py b/packages/backend/apps/multitenancy/migrations/0005_tenant_billing_email.py
new file mode 100644
index 000000000..89dc4c2d0
--- /dev/null
+++ b/packages/backend/apps/multitenancy/migrations/0005_tenant_billing_email.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2 on 2024-03-25 08:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('multitenancy', '0004_auto_20240318_1003'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tenant',
+ name='billing_email',
+ field=models.EmailField(
+ blank=True, db_collation='case_insensitive', max_length=255, verbose_name='billing email address'
+ ),
+ ),
+ ]
diff --git a/packages/backend/apps/multitenancy/migrations/0006_alter_tenantmembership_role.py b/packages/backend/apps/multitenancy/migrations/0006_alter_tenantmembership_role.py
new file mode 100644
index 000000000..0971d8d27
--- /dev/null
+++ b/packages/backend/apps/multitenancy/migrations/0006_alter_tenantmembership_role.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2 on 2024-03-27 13:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('multitenancy', '0005_tenant_billing_email'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='tenantmembership',
+ name='role',
+ field=models.CharField(
+ choices=[('OWNER', 'Owner'), ('ADMIN', 'Administrator'), ('MEMBER', 'Member')], default='OWNER'
+ ),
+ ),
+ ]
diff --git a/packages/backend/apps/multitenancy/migrations/0007_tenantmembership_creator.py b/packages/backend/apps/multitenancy/migrations/0007_tenantmembership_creator.py
new file mode 100644
index 000000000..b936ff3f7
--- /dev/null
+++ b/packages/backend/apps/multitenancy/migrations/0007_tenantmembership_creator.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.2 on 2024-04-04 18:56
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('multitenancy', '0006_alter_tenantmembership_role'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tenantmembership',
+ name='creator',
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='created_tenant_memberships',
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/packages/backend/apps/multitenancy/migrations/__init__.py b/packages/backend/apps/multitenancy/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/backend/apps/multitenancy/models.py b/packages/backend/apps/multitenancy/models.py
new file mode 100644
index 000000000..937d4dd69
--- /dev/null
+++ b/packages/backend/apps/multitenancy/models.py
@@ -0,0 +1,161 @@
+import hashid_field
+
+from django.db import models, IntegrityError, transaction
+from django.conf import settings
+from django.utils.text import slugify
+from django.db.models import UniqueConstraint, Q
+
+from . import constants
+from .managers import TenantManager, TenantMembershipManager
+from common.models import TimestampedMixin
+
+
+class Tenant(TimestampedMixin, models.Model):
+ """
+ Represents a tenant within the application.
+
+ Fields:
+ - id: A unique identifier for the tenant.
+ - creator: The user who created the tenant.
+ - name: The name of the tenant.
+ - slug: A URL-friendly version of the name.
+ - type: The type of the tenant.
+ - billing_email: Address used for billing purposes and it is provided to Stripe
+ - members: Many-to-many relationship with users through TenantMembership.
+
+ Methods:
+ - save: Overrides the default save method to ensure unique slug generation based on the name field.
+
+ Initialization:
+ - __original_name: Private attribute to track changes to the name field during the instance's lifecycle.
+
+ Slug Generation:
+ - The save method ensures the generation of a unique slug for the tenant. If the name is modified or the slug is
+ not provided, it generates a slug based on the name. In case of a name collision, a counter is appended to the
+ base slug to ensure uniqueness.
+ """
+
+ id: str = hashid_field.HashidAutoField(primary_key=True)
+ creator: settings.AUTH_USER_MODEL = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+ name: str = models.CharField(max_length=100, unique=False)
+ slug: str = models.SlugField(max_length=100, unique=True)
+ type: str = models.CharField(choices=constants.TenantType.choices)
+ members = models.ManyToManyField(
+ settings.AUTH_USER_MODEL,
+ through='TenantMembership',
+ related_name='tenants',
+ blank=True,
+ through_fields=('tenant', 'user'),
+ )
+ billing_email = models.EmailField(
+ db_collation="case_insensitive",
+ verbose_name="billing email address",
+ max_length=255,
+ unique=False,
+ blank=True,
+ )
+
+ objects = TenantManager()
+
+ MAX_SAVE_ATTEMPTS = 10
+
+ def __str__(self):
+ return self.name
+
+ def save(self, *args, **kwargs):
+ counter = 0
+ while counter < self.MAX_SAVE_ATTEMPTS:
+ try:
+ with transaction.atomic():
+ if not counter:
+ self.slug = slugify(self.name)
+ else:
+ self.slug = f"{slugify(self.name)}-{counter}"
+ super().save(*args, **kwargs)
+ break
+ except IntegrityError as e:
+ if 'duplicate key' in str(e).lower():
+ counter += 1
+ else:
+ raise e
+
+ @property
+ def email(self):
+ return self.billing_email if self.billing_email else self.creator.email
+
+ @property
+ def owners_count(self):
+ """
+ Calculate the total number of tenant owners for this tenant.
+ Returns the count of tenant owners.
+ """
+ return self.members.filter(tenant_memberships__role=constants.TenantUserRole.OWNER).count()
+
+ @property
+ def owners(self):
+ """
+ Returns the list of Users with an owner role.
+ """
+ return self.members.filter(tenant_memberships__role=constants.TenantUserRole.OWNER).all()
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.__original_name = self.name
+
+
+class TenantMembership(TimestampedMixin, models.Model):
+ """
+ Represents the membership of a user in a tenant. As well accepted as not accepted (invitations).
+
+ Fields:
+ - id: A unique identifier for the membership.
+ - user: The user associated with the membership.
+ - role: The role of the user in the tenant. Can be owner, admin or member.
+ - tenant: The tenant to which the user belongs.
+ - is_accepted: Indicates whether the membership invitation is accepted.
+ - invitation_accepted_at: Timestamp when the invitation was accepted.
+ - invitee_email_address: The email address of the invited user if not connected to an existing user.
+
+ Constraints:
+ - unique_non_null_user_and_tenant: Ensures the uniqueness of non-null user and tenant combinations.
+ - unique_non_null_user_and_invitee_email_address: Ensures the uniqueness of non-null user and invitee email address
+ combinations.
+ """
+
+ id: str = hashid_field.HashidAutoField(primary_key=True)
+ # User - Tenant connection fields
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="tenant_memberships", null=True
+ )
+ creator: settings.AUTH_USER_MODEL = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="created_tenant_memberships"
+ )
+ role = models.CharField(choices=constants.TenantUserRole.choices, default=constants.TenantUserRole.OWNER)
+ tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="user_memberships")
+
+ # Invitation connected fields
+ is_accepted = models.BooleanField(default=False)
+ invitation_accepted_at = models.DateTimeField(null=True)
+ invitee_email_address = models.EmailField(
+ db_collation="case_insensitive",
+ verbose_name="invitee email address",
+ max_length=255,
+ default="",
+ )
+
+ objects = TenantMembershipManager()
+
+ class Meta:
+ constraints = [
+ UniqueConstraint(
+ name="unique_non_null_user_and_tenant", fields=["user", "tenant"], condition=Q(user__isnull=False)
+ ),
+ UniqueConstraint(
+ name="unique_non_null_user_and_invitee_email_address",
+ fields=["invitee_email_address", "tenant"],
+ condition=~Q(invitee_email_address__exact=""),
+ ),
+ ]
+
+ def __str__(self):
+ return f"{self.user.email} {self.tenant.name} {self.role}"
diff --git a/packages/backend/apps/multitenancy/notifications.py b/packages/backend/apps/multitenancy/notifications.py
new file mode 100644
index 000000000..43e4dad02
--- /dev/null
+++ b/packages/backend/apps/multitenancy/notifications.py
@@ -0,0 +1,56 @@
+import logging
+
+from common import emails
+from apps.notifications import sender
+from . import constants
+from . import models
+from . import email_serializers
+
+logger = logging.getLogger(__name__)
+
+
+class TenantInvitationEmail(emails.Email):
+ name = 'TENANT_INVITATION'
+ serializer_class = email_serializers.TenantInvitationEmailSerializer
+
+
+def send_tenant_invitation_notification(tenant_membership: models.TenantMembership, membership_id: str, token: str):
+ if tenant_membership.user:
+ sender.send_notification(
+ user=tenant_membership.user,
+ type=constants.Notification.TENANT_INVITATION_CREATED.value,
+ data={
+ "id": membership_id,
+ "token": token,
+ "tenant_name": tenant_membership.tenant.name,
+ },
+ issuer=tenant_membership.creator,
+ )
+
+
+def send_accepted_tenant_invitation_notification(tenant_membership: models.TenantMembership, membership_id: str):
+ if tenant_membership.creator:
+ sender.send_notification(
+ user=tenant_membership.creator,
+ type=constants.Notification.TENANT_INVITATION_ACCEPTED.value,
+ data={
+ "id": membership_id,
+ "name": str(tenant_membership.user.profile) or str(tenant_membership.user),
+ "tenant_name": str(tenant_membership.tenant),
+ },
+ issuer=tenant_membership.user,
+ )
+
+
+def send_declined_tenant_invitation_notification(tenant_membership: models.TenantMembership, membership_id: str):
+ if tenant_membership.creator:
+ sender.send_notification(
+ user=tenant_membership.creator,
+ type=constants.Notification.TENANT_INVITATION_DECLINED.value,
+ data={
+ "id": membership_id,
+ "name": str(tenant_membership.user.profile) or str(tenant_membership.user),
+ "tenant_name": str(tenant_membership.tenant),
+ },
+ issuer=tenant_membership.user,
+ )
diff --git a/packages/backend/apps/multitenancy/schema.py b/packages/backend/apps/multitenancy/schema.py
new file mode 100644
index 000000000..ca44d71dc
--- /dev/null
+++ b/packages/backend/apps/multitenancy/schema.py
@@ -0,0 +1,278 @@
+import graphene
+from graphene import relay
+from graphql_relay import to_global_id, from_global_id
+from graphene_django import DjangoObjectType
+from django.shortcuts import get_object_or_404
+from rest_framework.exceptions import PermissionDenied
+
+from apps.users.services.users import get_user_from_resolver, get_user_avatar_url
+from common.acl import policies
+from common.graphql import mutations, exceptions
+from common.graphql.acl.decorators import permission_classes
+from common.graphql.acl.wrappers import PERMISSION_DENIED_MESSAGE
+from apps.finances.services import subscriptions
+from apps.finances.serializers import CancelTenantActiveSubscriptionSerializer
+from . import models
+from . import serializers
+from .tokens import tenant_invitation_token
+from .constants import TenantUserRole, TenantType as ConstantsTenantType
+
+
+TenantUserRoleType = graphene.Enum.from_enum(TenantUserRole)
+
+
+class TenantMembershipType(DjangoObjectType):
+ id = graphene.ID(required=True)
+ invitation_accepted = graphene.Boolean()
+ user_id = graphene.ID()
+ invitee_email_address = graphene.String()
+ invitation_token = graphene.String()
+ first_name = graphene.String()
+ last_name = graphene.String()
+ user_email = graphene.String()
+ avatar = graphene.String()
+ role = TenantUserRoleType()
+
+ class Meta:
+ model = models.TenantMembership
+ fields = (
+ "id",
+ "role",
+ "invitation_accepted",
+ "user_id",
+ "invitee_email_address",
+ "invitation_token",
+ "first_name",
+ "last_name",
+ "user_email",
+ "avatar",
+ )
+ interfaces = (relay.Node,)
+
+ def resolve_id(self, info):
+ return to_global_id("TenantMembershipType", self.id)
+
+ @staticmethod
+ def resolve_invitation_token(parent, info):
+ user = get_user_from_resolver(info)
+ if parent.user and user == parent.user and not parent.is_accepted:
+ return tenant_invitation_token.make_token(user.email, parent)
+ return None
+
+ @staticmethod
+ def resolve_invitation_accepted(parent, info):
+ return parent.is_accepted
+
+ @staticmethod
+ def resolve_first_name(parent, info):
+ return parent.user.profile.first_name if parent.user else None
+
+ @staticmethod
+ def resolve_last_name(parent, info):
+ return parent.user.profile.last_name if parent.user else None
+
+ @staticmethod
+ def resolve_user_email(parent, info):
+ return parent.user.email if parent.user else None
+
+ @staticmethod
+ def resolve_avatar(parent, info):
+ return get_user_avatar_url(parent.user) if parent.user else None
+
+
+class TenantType(DjangoObjectType):
+ id = graphene.ID(required=True)
+ name = graphene.String()
+ slug = graphene.String()
+ type = graphene.String()
+ billing_email = graphene.String()
+ membership = graphene.NonNull(of_type=TenantMembershipType)
+ user_memberships = graphene.List(of_type=TenantMembershipType)
+
+ class Meta:
+ model = models.Tenant
+ fields = ("id", "name", "slug", "billing_email", "type", "membership", "user_memberships")
+ interfaces = (relay.Node,)
+
+ @staticmethod
+ def resolve_membership(parent, info):
+ user = get_user_from_resolver(info)
+ return models.TenantMembership.objects.get_all().filter(user=user, tenant=parent).first()
+
+ def resolve_id(self, info):
+ return to_global_id("TenantType", self.id)
+
+ @staticmethod
+ @permission_classes(policies.IsTenantAdminAccess)
+ def resolve_user_memberships(parent, info):
+ return parent.user_memberships.get_all().filter(tenant=parent)
+
+
+class TenantConnection(graphene.Connection):
+ class Meta:
+ node = TenantType
+
+
+class CreateTenantMutation(mutations.CreateModelMutation):
+ class Meta:
+ serializer_class = serializers.TenantSerializer
+ edge_class = TenantConnection.Edge
+
+
+class UpdateTenantMutation(mutations.UpdateModelMutation):
+ class Meta:
+ serializer_class = serializers.TenantSerializer
+ edge_class = TenantConnection.Edge
+
+ @classmethod
+ def get_object(cls, model_class, root, info, **input):
+ return info.context.tenant
+
+
+class DeleteTenantMutation(mutations.DeleteModelMutation):
+ """
+ Mutation to delete a tenant from the system.
+ """
+
+ class Meta:
+ model = models.Tenant
+
+ @classmethod
+ def mutate_and_get_payload(cls, root, info, id, **kwargs):
+ """
+ Perform deletion of a tenant and subscription cancellation.
+
+ Returns:
+ DeleteTenantMutation: The mutation object with list of deleted_ids.
+
+ Raises:
+ GraphQlValidationError: If deletion encounters validation errors.
+ """
+ tenant = info.context.tenant
+
+ if tenant.type == ConstantsTenantType.DEFAULT:
+ raise exceptions.GraphQlValidationError("Cannot delete default type tenant.")
+
+ schedule = subscriptions.get_schedule(tenant)
+ cancel_subscription_serializer = CancelTenantActiveSubscriptionSerializer(instance=schedule, data={})
+ if cancel_subscription_serializer.is_valid():
+ cancel_subscription_serializer.save()
+
+ tenant.delete()
+ return cls(deleted_ids=[id])
+
+
+class CreateTenantInvitationMutation(mutations.SerializerMutation):
+ ok = graphene.Boolean()
+
+ class Meta:
+ serializer_class = serializers.CreateTenantInvitationSerializer
+
+
+class DeleteTenantMembershipMutation(mutations.DeleteModelMutation):
+ class Input:
+ id = graphene.String()
+ tenant_id = graphene.String(required=True)
+
+ class Meta:
+ model = models.TenantMembership
+
+ @classmethod
+ def get_object(cls, id, tenant, **kwargs):
+ model = cls._meta.model
+ _, pk = from_global_id(id)
+ return get_object_or_404(model.objects.get_all(), pk=pk, tenant=tenant)
+
+ @classmethod
+ def mutate_and_get_payload(cls, root, info, id, **kwargs):
+ obj = cls.get_object(id, info.context.tenant)
+ user = info.context.user
+ user_role = info.context.user_role
+
+ if user_role != TenantUserRole.OWNER and obj.user != user:
+ raise PermissionDenied(PERMISSION_DENIED_MESSAGE)
+
+ if obj.role == TenantUserRole.OWNER and info.context.tenant.owners_count == 1:
+ raise exceptions.GraphQlValidationError("There must be at least one owner in the Tenant.")
+
+ obj.delete()
+ return cls(deleted_ids=[id])
+
+
+class UpdateTenantMembershipMutation(mutations.UpdateModelMutation):
+ class Input:
+ id = graphene.String()
+ tenant_id = graphene.String(required=True)
+
+ class Meta:
+ serializer_class = serializers.UpdateTenantMembershipSerializer
+ edge_class = TenantConnection.Edge
+ convert_choices_to_enum = True
+
+ @classmethod
+ def get_object(cls, model_class, root, info, **input):
+ return get_object_or_404(model_class.objects.get_all(), pk=input["id"], tenant=info.context.tenant)
+
+ @classmethod
+ def mutate_and_get_payload(cls, root, info, **input):
+ if "id" in input:
+ _, input["id"] = from_global_id(input["id"])
+ return super().mutate_and_get_payload(root, info, **input)
+
+
+class AcceptTenantInvitationMutation(mutations.SerializerMutation):
+ ok = graphene.Boolean()
+
+ class Meta:
+ serializer_class = serializers.AcceptTenantInvitationSerializer
+
+ @classmethod
+ def mutate_and_get_payload(cls, root, info, **input):
+ if "id" in input:
+ _, input["id"] = from_global_id(input["id"])
+ return super().mutate_and_get_payload(root, info, **input)
+
+
+class DeclineTenantInvitationMutation(mutations.SerializerMutation):
+ ok = graphene.Boolean()
+
+ class Meta:
+ serializer_class = serializers.DeclineTenantInvitationSerializer
+
+ @classmethod
+ def mutate_and_get_payload(cls, root, info, **input):
+ if "id" in input:
+ _, input["id"] = from_global_id(input["id"])
+ return super().mutate_and_get_payload(root, info, **input)
+
+
+class Query(graphene.ObjectType):
+ all_tenants = graphene.relay.ConnectionField(TenantConnection)
+ tenant = graphene.Field(TenantType, id=graphene.ID())
+
+ @staticmethod
+ @permission_classes(policies.AnyoneFullAccess)
+ def resolve_all_tenants(root, info, **kwargs):
+ if info.context.user.is_authenticated:
+ return models.Tenant.objects.filter(user_memberships__user=info.context.user).all()
+ return []
+
+ @staticmethod
+ def resolve_tenant(root, info, id):
+ _, pk = from_global_id(id)
+ return models.Tenant.objects.filter(pk=pk, user_memberships__user=info.context.user).first()
+
+
+@permission_classes(policies.IsTenantOwnerAccess)
+class TenantOwnerMutation(graphene.ObjectType):
+ update_tenant = UpdateTenantMutation.Field()
+ delete_tenant = DeleteTenantMutation.Field()
+ create_tenant_invitation = CreateTenantInvitationMutation.Field()
+ update_tenant_membership = UpdateTenantMembershipMutation.Field()
+
+
+class Mutation(graphene.ObjectType):
+ create_tenant = CreateTenantMutation.Field()
+ accept_tenant_invitation = AcceptTenantInvitationMutation.Field()
+ decline_tenant_invitation = DeclineTenantInvitationMutation.Field()
+ delete_tenant_membership = DeleteTenantMembershipMutation.Field()
diff --git a/packages/backend/apps/multitenancy/serializers.py b/packages/backend/apps/multitenancy/serializers.py
new file mode 100644
index 000000000..d647fc89f
--- /dev/null
+++ b/packages/backend/apps/multitenancy/serializers.py
@@ -0,0 +1,169 @@
+from hashid_field import rest as hidrest
+from rest_framework import serializers, exceptions
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import BaseUserManager
+from django.utils.translation import gettext_lazy as _
+from django.db.models import Q
+from django.utils import timezone
+from graphql_relay import to_global_id
+
+from common.graphql.field_conversions import TextChoicesFieldType
+from . import models, notifications
+from .constants import TenantType, TenantUserRole
+from .services.membership import create_tenant_membership
+from .tokens import tenant_invitation_token
+
+
+class TenantSerializer(serializers.ModelSerializer):
+ id = hidrest.HashidSerializerCharField(source_field="multitenancy.Tenant.id", read_only=True)
+
+ def create(self, validated_data):
+ validated_data["creator"] = self.context["request"].user
+ validated_data["type"] = TenantType.ORGANIZATION
+ tenant = super().create(validated_data)
+ create_tenant_membership(
+ user=validated_data["creator"], tenant=tenant, role=TenantUserRole.OWNER, is_accepted=True
+ )
+ return tenant
+
+ class Meta:
+ model = models.Tenant
+ fields = ("id", "name", "billing_email")
+
+
+class TenantInvitationActionSerializer(serializers.Serializer):
+ """
+ Parent serializer for Accept and Decline serializers.
+
+ It validates if invitation exists and if token is correct before proceeding with invitation action.
+ """
+
+ id = hidrest.HashidSerializerCharField(source_field="multitenancy.TenantMembership.id", write_only=True)
+ token = serializers.CharField(write_only=True, help_text=_("Token"))
+ ok = serializers.BooleanField(read_only=True)
+
+ def validate(self, attrs):
+ membership_id = attrs["id"]
+ user = self.context["request"].user
+ membership = models.TenantMembership.objects.get_not_accepted().filter(pk=membership_id, user=user).first()
+
+ if not membership:
+ raise exceptions.NotFound("Invitation not found.")
+
+ if not tenant_invitation_token.check_token(user.email, attrs["token"], membership):
+ raise exceptions.ValidationError(_("Malformed tenant invitation token"))
+
+ return attrs
+
+
+class AcceptTenantInvitationSerializer(TenantInvitationActionSerializer):
+ """
+ Updates not accepted invitation membership object to be accepted one.
+ """
+
+ def create(self, validated_data):
+ membership_id = validated_data["id"]
+ user = self.context["request"].user
+ membership = models.TenantMembership.objects.get_not_accepted().filter(pk=membership_id, user=user).first()
+ if membership:
+ models.TenantMembership.objects.get_not_accepted().filter(pk=membership_id, user=user).update(
+ is_accepted=True, invitation_accepted_at=timezone.now()
+ )
+ notifications.send_accepted_tenant_invitation_notification(
+ membership, to_global_id("TenantMembershipType", membership_id)
+ )
+ return {"ok": True}
+
+
+class DeclineTenantInvitationSerializer(TenantInvitationActionSerializer):
+ """
+ Removes membership object if user decides to decline invitation.
+ """
+
+ def create(self, validated_data):
+ membership_id = validated_data["id"]
+ user = self.context["request"].user
+ membership = models.TenantMembership.objects.get_not_accepted().filter(pk=membership_id, user=user).first()
+ if membership:
+ membership.delete()
+ notifications.send_declined_tenant_invitation_notification(
+ membership, to_global_id("TenantMembershipType", membership_id)
+ )
+ return {"ok": True}
+
+
+class CreateTenantInvitationSerializer(serializers.Serializer):
+ """
+ Serializer for creating a not-yet-accepted membership invitation.
+
+ This serializer is designed to handle the creation of a membership invitation within a tenant.
+ It validates the input data, ensuring that the connection between the specified user or invitee email
+ and the tenant does not already exist. If the connection is valid, it creates a new not accepted membership object.
+ """
+
+ email = serializers.EmailField(required=True)
+ role = TextChoicesFieldType(choices=TenantUserRole.choices, choices_class=TenantUserRole)
+ tenant_id = serializers.CharField()
+ ok = serializers.BooleanField(read_only=True)
+
+ def validate(self, attrs):
+ email = BaseUserManager.normalize_email(attrs["email"])
+ tenant = self.context["request"].tenant
+ if tenant.type == TenantType.DEFAULT:
+ raise serializers.ValidationError(_("Invitation for personal tenant cannot be created."))
+ if (
+ models.TenantMembership.objects.get_all()
+ .filter(Q(user__email=email, tenant=tenant) | Q(invitee_email_address=email, tenant=tenant))
+ .exists()
+ ):
+ raise serializers.ValidationError(_("Invitation already exists"))
+ return super().validate(attrs)
+
+ def create(self, validated_data):
+ email = BaseUserManager.normalize_email(validated_data["email"])
+ role = validated_data["role"]
+ tenant = self.context["request"].tenant
+ User = get_user_model()
+ tenant_membership_data = {
+ "role": role,
+ "tenant": tenant,
+ "creator": self.context["request"].user,
+ }
+ try:
+ tenant_membership_data["user"] = User.objects.get(email=email)
+ except User.DoesNotExist:
+ tenant_membership_data["invitee_email_address"] = email
+
+ create_tenant_membership(**tenant_membership_data)
+
+ return {"ok": True, **validated_data}
+
+
+class UpdateTenantMembershipSerializer(serializers.ModelSerializer):
+ """
+ Serializer for update a tenant membership.
+
+ This serializer is designed to handle the update of a membership within a tenant.
+ """
+
+ id = hidrest.HashidSerializerCharField(source_field="multitenancy.TenantMembership.id", write_only=True)
+ role = TextChoicesFieldType(choices=TenantUserRole.choices, choices_class=TenantUserRole)
+
+ def validate(self, attrs):
+ tenant = self.context["request"].tenant
+ actual_value = models.TenantMembership.objects.get_all().filter(pk=attrs["id"]).first()
+ if (
+ actual_value
+ and actual_value.role == TenantUserRole.OWNER
+ and attrs["role"] != TenantUserRole.OWNER
+ and tenant.owners_count == 1
+ ):
+ raise exceptions.ValidationError("There must be at least one owner in the Tenant.")
+ return super().validate(attrs)
+
+ class Meta:
+ model = models.TenantMembership
+ fields = (
+ "id",
+ "role",
+ )
diff --git a/packages/backend/apps/multitenancy/services/membership.py b/packages/backend/apps/multitenancy/services/membership.py
new file mode 100644
index 000000000..96948663f
--- /dev/null
+++ b/packages/backend/apps/multitenancy/services/membership.py
@@ -0,0 +1,41 @@
+from graphql_relay import to_global_id
+from django.contrib.auth import get_user_model
+
+from ..models import Tenant, TenantMembership
+from ..constants import TenantUserRole
+from ..tokens import tenant_invitation_token
+from ..notifications import TenantInvitationEmail, send_tenant_invitation_notification
+
+User = get_user_model()
+
+
+def create_tenant_membership(
+ tenant: Tenant,
+ user: User = None,
+ creator: User = None,
+ invitee_email_address: str = "",
+ role: TenantUserRole = TenantUserRole.MEMBER,
+ is_accepted: bool = False,
+):
+ membership = TenantMembership.objects.create(
+ user=user,
+ tenant=tenant,
+ role=role,
+ invitee_email_address=invitee_email_address,
+ is_accepted=is_accepted,
+ creator=creator,
+ )
+ if not is_accepted:
+ token = tenant_invitation_token.make_token(
+ user_email=invitee_email_address if invitee_email_address else user.email, tenant_membership=membership
+ )
+ global_tenant_membership_id = to_global_id("TenantMembershipType", membership.id)
+ TenantInvitationEmail(
+ to=user.email if user else invitee_email_address,
+ data={'tenant_membership_id': global_tenant_membership_id, 'token': token},
+ ).send()
+
+ if user:
+ send_tenant_invitation_notification(membership, global_tenant_membership_id, token)
+
+ return membership
diff --git a/packages/backend/apps/multitenancy/tests/__init__.py b/packages/backend/apps/multitenancy/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/backend/apps/multitenancy/tests/factories.py b/packages/backend/apps/multitenancy/tests/factories.py
new file mode 100644
index 000000000..53862de00
--- /dev/null
+++ b/packages/backend/apps/multitenancy/tests/factories.py
@@ -0,0 +1,30 @@
+import factory
+
+from .. import models
+from .. import constants
+
+
+class TenantFactory(factory.django.DjangoModelFactory):
+ creator = factory.SubFactory("apps.users.tests.factories.UserFactory")
+ type = factory.Iterator(constants.TenantType.values)
+ name = factory.Faker('pystr')
+ slug = factory.Faker('pystr')
+ billing_email = factory.Faker('email')
+ created_at = factory.Faker('date_time')
+ updated_at = factory.Faker('date_time')
+
+ class Meta:
+ model = models.Tenant
+
+
+class TenantMembershipFactory(factory.django.DjangoModelFactory):
+ user = factory.SubFactory("apps.users.tests.factories.UserFactory")
+ creator = factory.SubFactory("apps.users.tests.factories.UserFactory")
+ tenant = factory.SubFactory(TenantFactory)
+ role = factory.Iterator(constants.TenantUserRole.values)
+ created_at = factory.Faker('date_time')
+ updated_at = factory.Faker('date_time')
+ is_accepted = True
+
+ class Meta:
+ model = models.TenantMembership
diff --git a/packages/backend/apps/multitenancy/tests/fixtures.py b/packages/backend/apps/multitenancy/tests/fixtures.py
new file mode 100644
index 000000000..1bee31053
--- /dev/null
+++ b/packages/backend/apps/multitenancy/tests/fixtures.py
@@ -0,0 +1,6 @@
+import pytest_factoryboy
+
+from . import factories
+
+pytest_factoryboy.register(factories.TenantFactory)
+pytest_factoryboy.register(factories.TenantMembershipFactory)
diff --git a/packages/backend/apps/multitenancy/tests/test_middleware.py b/packages/backend/apps/multitenancy/tests/test_middleware.py
new file mode 100644
index 000000000..cf1c63bb4
--- /dev/null
+++ b/packages/backend/apps/multitenancy/tests/test_middleware.py
@@ -0,0 +1,88 @@
+import pytest
+from graphql_relay import to_global_id
+from unittest.mock import Mock
+
+from ..middleware import get_current_tenant, get_current_user_role, TenantUserRoleMiddleware
+
+
+pytestmark = pytest.mark.django_db
+
+
+class TestGetTenantIdFromArguments:
+ def test_get_tenant_id_from_arguments_with_input(self, tenant):
+ args = {"input": {"tenant_id": to_global_id("TenantType", tenant.id)}}
+ result = TenantUserRoleMiddleware._get_tenant_id_from_arguments(args)
+ assert result == tenant.id
+
+ def test_get_tenant_id_from_arguments_without_input(self, tenant):
+ args = {"id": to_global_id("TenantType", tenant.id)}
+ result = TenantUserRoleMiddleware._get_tenant_id_from_arguments(args)
+ assert result == tenant.id
+
+ def test_get_tenant_id_from_arguments_invalid_id_type(self):
+ args = {"input": {"tenant_id": "InvalidType:123"}}
+ result = TenantUserRoleMiddleware._get_tenant_id_from_arguments(args)
+ assert result is None
+
+ def test_get_tenant_id_from_arguments_no_tenant_id(self):
+ args = {"input": {"other_field": "value"}}
+ result = TenantUserRoleMiddleware._get_tenant_id_from_arguments(args)
+ assert result is None
+
+ def test_get_tenant_id_from_arguments_no_args(self):
+ args = {}
+ result = TenantUserRoleMiddleware._get_tenant_id_from_arguments(args)
+ assert result is None
+
+
+class TestTenantUserRoleMiddlewareGetCurrentTenant:
+ def test_get_current_tenant_with_tenant_id(self, tenant_factory):
+ tenant_factory.create_batch(10)
+ tenant = tenant_factory(name="Test Tenant")
+ result = get_current_tenant(tenant.id)
+ assert result == tenant
+
+ def test_get_current_tenant_nonexistent_tenant(self, tenant_factory):
+ tenant_factory.create_batch(10)
+ tenant_factory(name="Test Tenant")
+ result = get_current_tenant("9999")
+ assert result is None
+
+ def test_get_current_tenant_missing_tenant_id(self, tenant_factory):
+ tenant_factory.create_batch(10)
+ tenant_factory(name="Test Tenant")
+ result = get_current_tenant(None)
+ assert result is None
+
+
+class TestTenantUserRoleMiddlewareGetCurrentUserRole:
+ def test_get_current_user_role_authenticated_user(self, graphene_client, tenant, user, tenant_membership_factory):
+ tenant_membership = tenant_membership_factory(user=user, tenant=tenant, role="admin")
+ info = Mock()
+ info.context.user = user
+ info.context.tenant = tenant
+ info.context.tenant_id = tenant.id
+ graphene_client.force_authenticate(user)
+ result = get_current_user_role(info.context.tenant, info.context.user)
+ assert result == tenant_membership.role
+
+ def test_get_current_user_role_unauthenticated_user(self, tenant, user, tenant_membership_factory):
+ tenant_membership_factory(user=user, tenant=tenant, role="admin")
+ info = Mock()
+ info.context.user = None
+ info.context.tenant = tenant
+ info.context.tenant_id = tenant.id
+ result = get_current_user_role(info.context.tenant, info.context.user)
+ assert result is None
+
+ def test_get_current_user_role_membership_does_not_exist(
+ self, graphene_client, tenant, user, tenant_membership_factory
+ ):
+ tenant_membership_factory(user=user, tenant=tenant, role="admin")
+ info = Mock()
+ info.context.user = user
+ info.context.tenant = None
+ info.context.tenant_id = None
+ graphene_client.force_authenticate(user)
+ result = get_current_user_role(info.context.tenant, info.context.user)
+ assert result is None
diff --git a/packages/backend/apps/multitenancy/tests/test_models.py b/packages/backend/apps/multitenancy/tests/test_models.py
new file mode 100644
index 000000000..afd0a7a83
--- /dev/null
+++ b/packages/backend/apps/multitenancy/tests/test_models.py
@@ -0,0 +1,55 @@
+import pytest
+from unittest.mock import patch
+from django.utils.text import slugify
+from django.db import IntegrityError
+
+from ..models import Tenant
+
+pytestmark = pytest.mark.django_db
+
+
+class TestTenant:
+ def test_save_unique_slug_generation(self, user):
+ with patch("apps.multitenancy.models.slugify", side_effect=slugify) as mock_slugify:
+ tenant = Tenant(name="Test Tenant", creator=user)
+ tenant.save()
+
+ mock_slugify.assert_called_once_with("Test Tenant")
+ assert tenant.slug == "test-tenant"
+
+ def test_save_unique_slug_with_collision(self, user, tenant_factory):
+ tenant_factory(name="Test Tenant", creator=user)
+ with patch("apps.multitenancy.models.slugify", side_effect=slugify) as mock_slugify:
+ tenant = Tenant(name="Test Tenant", creator=user)
+ tenant.save()
+
+ mock_slugify.assert_called_with("Test Tenant")
+ assert mock_slugify.call_count == 2
+ assert tenant.slug == "test-tenant-1"
+
+ def test_save_unique_slug_raises_different_integrity_error(self):
+ tenant = Tenant(name="Test Tenant")
+ try:
+ tenant.save()
+ except IntegrityError as e:
+ assert "not-null constraint" in str(e).lower()
+
+
+class TestTenantMembership:
+ def test_unique_non_null_user_and_tenant(self, tenant, user, tenant_membership_factory):
+ tenant_membership_factory(user=user, tenant=tenant)
+ try:
+ tenant_membership_factory(user=user, tenant=tenant)
+ except IntegrityError:
+ pass
+ else:
+ assert False, "IntegrityError not raised"
+
+ def test_unique_non_null_user_and_invitee_email_address(self, tenant, tenant_membership_factory):
+ tenant_membership_factory(invitee_email_address="user@example.com", tenant=tenant)
+ try:
+ tenant_membership_factory(invitee_email_address="user@example.com", tenant=tenant)
+ except IntegrityError:
+ pass
+ else:
+ assert False, "IntegrityError not raised"
diff --git a/packages/backend/apps/multitenancy/tests/test_schema.py b/packages/backend/apps/multitenancy/tests/test_schema.py
new file mode 100644
index 000000000..07b386bcb
--- /dev/null
+++ b/packages/backend/apps/multitenancy/tests/test_schema.py
@@ -0,0 +1,1260 @@
+import pytest
+import os
+from graphql_relay import to_global_id
+
+from apps.notifications.models import Notification
+from ..constants import TenantType, TenantUserRole, Notification as NotificationConstant
+from ..models import TenantMembership
+
+
+pytestmark = pytest.mark.django_db
+
+
+class TestCreateTenantMutation:
+ MUTATION = '''
+ mutation CreateTenant($input: CreateTenantMutationInput!) {
+ createTenant(input: $input) {
+ tenant {
+ id
+ name
+ slug
+ type
+ billingEmail
+ membership {
+ role
+ invitationAccepted
+ }
+ }
+ }
+ }
+ '''
+
+ def test_create_new_tenant(self, graphene_client, user):
+ graphene_client.force_authenticate(user)
+ executed = self.mutate(graphene_client, {"name": "Test", "billingEmail": "test@example.com"})
+ response_data = executed["data"]["createTenant"]["tenant"]
+ assert response_data["name"] == "Test"
+ assert response_data["slug"] == "test"
+ assert response_data["type"] == TenantType.ORGANIZATION
+ assert response_data["billingEmail"] == "test@example.com"
+ assert response_data["membership"]["role"] == TenantUserRole.OWNER
+
+ def test_create_new_tenant_with_same_name(self, graphene_client, user, tenant_factory):
+ tenant_factory(name="Test", slug="test")
+ graphene_client.force_authenticate(user)
+ executed = self.mutate(graphene_client, {"name": "Test"})
+ response_data = executed["data"]["createTenant"]["tenant"]
+ assert response_data["name"] == "Test"
+ assert response_data["slug"] == "test-1"
+ assert response_data["type"] == TenantType.ORGANIZATION
+ assert response_data["membership"]["role"] == TenantUserRole.OWNER
+
+ def test_unauthenticated_user(self, graphene_client):
+ executed = self.mutate(graphene_client, {"name": "Test"})
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ @classmethod
+ def mutate(cls, graphene_client, data):
+ return graphene_client.mutate(cls.MUTATION, variable_values={'input': data})
+
+
+class TestUpdateTenantMutation:
+ MUTATION = '''
+ mutation UpdateTenant($input: UpdateTenantMutationInput!) {
+ updateTenant(input: $input) {
+ tenant {
+ id
+ name
+ slug
+ type
+ billingEmail
+ membership {
+ role
+ invitationAccepted
+ }
+ }
+ }
+ }
+ '''
+
+ def test_update_tenant(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {"id": to_global_id("TenantType", tenant.id), "name": "Tenant 2", "billingEmail": "test@example.com"},
+ )
+ response_data = executed["data"]["updateTenant"]["tenant"]
+ assert response_data["name"] == "Tenant 2"
+ assert response_data["slug"] == "tenant-2"
+ assert response_data["type"] == TenantType.ORGANIZATION
+ assert response_data["billingEmail"] == "test@example.com"
+ assert response_data["membership"]["role"] == TenantUserRole.OWNER
+
+ def test_user_without_membership(self, graphene_client, user, tenant_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+ executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id), "name": "Tenant 2"})
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_user_with_admin_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id), "name": "Tenant 2"})
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_user_with_member_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
+ executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id), "name": "Tenant 2"})
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_unauthenticated_user(self, graphene_client, tenant_factory):
+ tenant = tenant_factory(name="Tenant 1")
+ executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id), "name": "Tenant 2"})
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ @classmethod
+ def mutate(cls, graphene_client, data):
+ return graphene_client.mutate(cls.MUTATION, variable_values={'input': data})
+
+
+class TestDeleteTenantMutation:
+ MUTATION = '''
+ mutation DeleteTenant($input: DeleteTenantMutationInput!) {
+ deleteTenant(input: $input) {
+ deletedIds
+ }
+ }
+ '''
+
+ def test_delete_tenant(
+ self,
+ graphene_client,
+ user,
+ tenant_factory,
+ tenant_membership_factory,
+ subscription_schedule_factory,
+ monthly_plan_price,
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_id = tenant.id
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ subscription_schedule_factory(
+ phases=[{'items': [{'price': monthly_plan_price.id}], 'trialing': True}], customer__subscriber=tenant
+ )
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)})
+ response_data = executed["data"]["deleteTenant"]["deletedIds"]
+ assert response_data[0] == to_global_id("TenantType", tenant_id)
+
+ def test_delete_tenant_with_free_plan(
+ self,
+ graphene_client,
+ user,
+ tenant_factory,
+ tenant_membership_factory,
+ subscription_schedule_factory,
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_id = tenant.id
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ subscription_schedule_factory(phases=None, customer__subscriber=tenant)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)})
+ response_data = executed["data"]["deleteTenant"]["deletedIds"]
+ assert response_data[0] == to_global_id("TenantType", tenant_id)
+
+ def test_delete_default_tenant(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.DEFAULT)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)})
+ assert executed["errors"][0]["message"] == "GraphQlValidationError"
+
+ def test_user_without_membership(self, graphene_client, user, tenant_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+ executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)})
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_user_with_admin_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)})
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_user_with_member_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
+ executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)})
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_unauthenticated_user(self, graphene_client, tenant_factory):
+ tenant = tenant_factory(name="Tenant 1")
+ executed = self.mutate(graphene_client, {"id": to_global_id("TenantType", tenant.id)})
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ @classmethod
+ def mutate(cls, graphene_client, data):
+ return graphene_client.mutate(cls.MUTATION, variable_values={'input': data})
+
+
+class TestCreateTenantInvitationMutation:
+ MUTATION = '''
+ mutation CreateTenantInvitation($input: CreateTenantInvitationMutationInput!) {
+ createTenantInvitation(input: $input) {
+ ok
+ email
+ role
+ tenantId
+ }
+ }
+ '''
+
+ def test_create_tenant_invitation_by_owner(
+ self, mocker, graphene_client, user, user_factory, tenant_factory, tenant_membership_factory
+ ):
+ make_token = mocker.patch(
+ "apps.multitenancy.tokens.TenantInvitationTokenGenerator.make_token", return_value="token"
+ )
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ invited_user = user_factory()
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "email": invited_user.email,
+ "role": TenantUserRole.ADMIN,
+ },
+ )
+ response_data = executed["data"]["createTenantInvitation"]
+ assert response_data["ok"] is True
+ assert response_data["email"] == invited_user.email
+ assert response_data["role"] == TenantUserRole.ADMIN
+ assert response_data["tenantId"] == to_global_id("TenantType", tenant.id)
+ make_token.assert_called_once()
+
+ assert Notification.objects.count() == 1
+ notification = Notification.objects.first()
+ assert notification.type == NotificationConstant.TENANT_INVITATION_CREATED.value
+ assert notification.user == invited_user
+ assert notification.issuer == user
+
+ def test_create_default_tenant_invitation_by_owner(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.DEFAULT)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "email": "test@example.com",
+ "role": TenantUserRole.ADMIN.upper(),
+ },
+ )
+ assert executed["errors"][0]["message"] == "GraphQlValidationError"
+
+ def test_create_tenant_invitation_by_admin(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "email": "test@example.com",
+ "role": TenantUserRole.ADMIN.upper(),
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_create_tenant_invitation_by_member(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "email": "test@example.com",
+ "role": TenantUserRole.ADMIN.upper(),
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_user_without_membership(self, graphene_client, user, tenant_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "email": "test@example.com",
+ "role": TenantUserRole.ADMIN.upper(),
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_unauthenticated_user(self, graphene_client, tenant_factory):
+ tenant = tenant_factory(name="Tenant 1")
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "email": "test@example.com",
+ "role": TenantUserRole.ADMIN.upper(),
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ @classmethod
+ def mutate(cls, graphene_client, data):
+ return graphene_client.mutate(cls.MUTATION, variable_values={'input': data})
+
+
+class TestDeleteTenantMembershipMutation:
+ MUTATION = '''
+ mutation DeleteTenantMembership($input: DeleteTenantMembershipMutationInput!) {
+ deleteTenantMembership(input: $input) {
+ deletedIds
+ }
+ }
+ '''
+
+ def test_delete_tenant_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ },
+ )
+ assert executed["data"]["deleteTenantMembership"]["deletedIds"][0] == to_global_id(
+ "TenantMembershipType", tenant_membership.id
+ )
+
+ def test_delete_own_tenant_membership_by_member(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ },
+ )
+ assert executed["data"]["deleteTenantMembership"]["deletedIds"][0] == to_global_id(
+ "TenantMembershipType", tenant_membership.id
+ )
+
+ def test_delete_own_tenant_membership_by_admin(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ },
+ )
+ assert executed["data"]["deleteTenantMembership"]["deletedIds"][0] == to_global_id(
+ "TenantMembershipType", tenant_membership.id
+ )
+
+ def test_delete_tenant_membership_not_accepted(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, is_accepted=False)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ },
+ )
+ assert executed["data"]["deleteTenantMembership"]["deletedIds"][0] == to_global_id(
+ "TenantMembershipType", tenant_membership.id
+ )
+
+ def test_delete_tenant_membership_with_invalid_id(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", "InvalidID"),
+ },
+ )
+ assert executed["errors"][0]["message"] == "No TenantMembership matches the given query."
+
+ def test_delete_default_tenant_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.DEFAULT)
+ tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ },
+ )
+ assert executed["errors"][0]["message"] == "GraphQlValidationError"
+
+ def test_delete_organization_tenant_membership_last_owner(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ },
+ )
+ assert executed["errors"][0]["message"] == "GraphQlValidationError"
+
+ def test_delete_tenant_membership_with_different_tenant_id(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_2 = tenant_factory(name="Tenant 2", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ tenant_membership_factory(tenant=tenant_2, user=user, role=TenantUserRole.OWNER)
+ tenant_membership = tenant_membership_factory(tenant=tenant_2, role=TenantUserRole.MEMBER)
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ },
+ )
+ assert executed["errors"][0]["message"] == "No TenantMembership matches the given query."
+
+ def test_delete_tenant_membership_by_member(
+ self, graphene_client, user, user_factory, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
+ tenant_membership = tenant_membership_factory(tenant=tenant, user=user_factory(), role=TenantUserRole.MEMBER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_delete_tenant_membership_by_admin(
+ self, graphene_client, user, user_factory, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN)
+ tenant_membership = tenant_membership_factory(tenant=tenant, user=user_factory(), role=TenantUserRole.MEMBER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_delete_tenant_membership_by_not_a_member(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_delete_tenant_membership_by_unauthorized(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ @classmethod
+ def mutate(cls, graphene_client, data):
+ return graphene_client.mutate(cls.MUTATION, variable_values={'input': data})
+
+
+class TestUpdateTenantMembershipMutation:
+ MUTATION = '''
+ mutation UpdateTenantMembership($input: UpdateTenantMembershipMutationInput!) {
+ updateTenantMembership(input: $input) {
+ tenantMembership {
+ id
+ role
+ }
+ }
+ }
+ '''
+
+ def test_update_tenant_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ "role": "ADMIN",
+ },
+ )
+ data = executed["data"]["updateTenantMembership"]["tenantMembership"]
+ assert data["id"] == to_global_id("TenantMembershipType", tenant_membership.id)
+ assert data["role"] == TenantUserRole.ADMIN
+
+ def test_update_tenant_membership_not_accepted(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, is_accepted=False)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ "role": "ADMIN",
+ },
+ )
+ print(executed)
+ data = executed["data"]["updateTenantMembership"]["tenantMembership"]
+ assert data["id"] == to_global_id("TenantMembershipType", tenant_membership.id)
+ assert data["role"] == TenantUserRole.ADMIN
+
+ def test_update_own_tenant_membership_by_member(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.MEMBER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ "role": "ADMIN",
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_update_own_tenant_membership_by_admin(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.ADMIN)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.ADMIN)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ "role": "MEMBER",
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_update_tenant_membership_with_invalid_id(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", "InvalidID"),
+ "role": "ADMIN",
+ },
+ )
+ assert executed["errors"][0]["message"] == "No TenantMembership matches the given query."
+
+ def test_update_default_tenant_membership(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.DEFAULT)
+ tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ "role": "ADMIN",
+ },
+ )
+ assert executed["errors"][0]["message"] == "GraphQlValidationError"
+
+ def test_update_organization_tenant_membership_last_owner(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ "role": "ADMIN",
+ },
+ )
+ assert executed["errors"][0]["message"] == "GraphQlValidationError"
+
+ def test_update_tenant_membership_with_different_tenant_id(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_2 = tenant_factory(name="Tenant 2", type=TenantType.ORGANIZATION)
+ tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.OWNER)
+ tenant_membership_factory(tenant=tenant_2, user=user, role=TenantUserRole.OWNER)
+ tenant_membership = tenant_membership_factory(tenant=tenant_2, role=TenantUserRole.MEMBER)
+
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, TenantUserRole.OWNER)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ "role": "ADMIN",
+ },
+ )
+ assert executed["errors"][0]["message"] == "No TenantMembership matches the given query."
+
+ def test_update_tenant_membership_by_not_a_member(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ "role": "ADMIN",
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ def test_update_tenant_membership_by_unauthorized(
+ self, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ tenant_membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER)
+ graphene_client.set_tenant_dependent_context(tenant, None)
+ executed = self.mutate(
+ graphene_client,
+ {
+ "tenantId": to_global_id("TenantType", tenant.id),
+ "id": to_global_id("TenantMembershipType", tenant_membership.id),
+ "role": "ADMIN",
+ },
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ @classmethod
+ def mutate(cls, graphene_client, data):
+ return graphene_client.mutate(cls.MUTATION, variable_values={'input': data})
+
+
+class TestAcceptTenantInvitationMutation:
+ MUTATION = '''
+ mutation AcceptTenantInvitation($input: AcceptTenantInvitationMutationInput!) {
+ acceptTenantInvitation(input: $input) {
+ ok
+ }
+ }
+ '''
+
+ def test_accept_invitation_by_invitee(
+ self, mocker, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ check_token = mocker.patch(
+ "apps.multitenancy.tokens.TenantInvitationTokenGenerator.check_token", return_value=True
+ )
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False)
+ graphene_client.force_authenticate(user)
+ executed = self.mutate(
+ graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"}
+ )
+ response_data = executed["data"]["acceptTenantInvitation"]
+ assert response_data["ok"] is True
+ membership = TenantMembership.objects.filter(tenant=tenant, user=user).first()
+ assert membership.is_accepted
+ assert membership.invitation_accepted_at
+ check_token.assert_called_once()
+
+ assert Notification.objects.count() == 1
+ notification = Notification.objects.first()
+ assert notification.type == NotificationConstant.TENANT_INVITATION_ACCEPTED.value
+ assert notification.user == membership.creator
+ assert notification.issuer == user
+
+ def test_accept_invitation_by_invitee_wrong_token(
+ self, mocker, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ check_token = mocker.patch(
+ "apps.multitenancy.tokens.TenantInvitationTokenGenerator.check_token", return_value=False
+ )
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False)
+ graphene_client.force_authenticate(user)
+ executed = self.mutate(
+ graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"}
+ )
+ assert executed["errors"][0]["message"] == "GraphQlValidationError"
+ check_token.assert_called_once()
+
+ def test_accept_invitation_by_other_user(
+ self, graphene_client, user_factory, tenant_factory, tenant_membership_factory
+ ):
+ logged_user = user_factory()
+ invitee_user = user_factory()
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ membership = tenant_membership_factory(
+ tenant=tenant, user=invitee_user, role=TenantUserRole.MEMBER, is_accepted=False
+ )
+ graphene_client.force_authenticate(logged_user)
+ executed = self.mutate(
+ graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"}
+ )
+ assert executed["errors"][0]["message"] == "Invitation not found."
+
+ def test_unauthenticated_user(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False)
+ executed = self.mutate(
+ graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"}
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ @classmethod
+ def mutate(cls, graphene_client, data):
+ return graphene_client.mutate(cls.MUTATION, variable_values={'input': data})
+
+
+class TestDeclineTenantInvitationMutation:
+ MUTATION = '''
+ mutation DeclineTenantInvitation($input:DeclineTenantInvitationMutationInput!) {
+ declineTenantInvitation(input: $input) {
+ ok
+ }
+ }
+ '''
+
+ def test_decline_invitation_by_invitee(
+ self, mocker, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ check_token = mocker.patch(
+ "apps.multitenancy.tokens.TenantInvitationTokenGenerator.check_token", return_value=True
+ )
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False)
+ graphene_client.force_authenticate(user)
+ executed = self.mutate(
+ graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"}
+ )
+ response_data = executed["data"]["declineTenantInvitation"]
+ assert response_data["ok"] is True
+ assert not TenantMembership.objects.filter(tenant=tenant, user=user).exists()
+ check_token.assert_called_once()
+
+ assert Notification.objects.count() == 1
+ notification = Notification.objects.first()
+ assert notification.type == NotificationConstant.TENANT_INVITATION_DECLINED.value
+ assert notification.user == membership.creator
+ assert notification.issuer == user
+
+ def test_decline_invitation_by_invitee_wrong_token(
+ self, mocker, graphene_client, user, tenant_factory, tenant_membership_factory
+ ):
+ check_token = mocker.patch(
+ "apps.multitenancy.tokens.TenantInvitationTokenGenerator.check_token", return_value=False
+ )
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False)
+ graphene_client.force_authenticate(user)
+ executed = self.mutate(
+ graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"}
+ )
+ assert executed["errors"][0]["message"] == "GraphQlValidationError"
+ check_token.assert_called_once()
+
+ def test_decline_invitation_by_other_user(
+ self, graphene_client, user_factory, tenant_factory, tenant_membership_factory
+ ):
+ logged_user = user_factory()
+ invitee_user = user_factory()
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ membership = tenant_membership_factory(
+ tenant=tenant, user=invitee_user, role=TenantUserRole.MEMBER, is_accepted=False
+ )
+ graphene_client.force_authenticate(logged_user)
+ executed = self.mutate(
+ graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"}
+ )
+ assert executed["errors"][0]["message"] == "Invitation not found."
+
+ def test_unauthenticated_user(self, graphene_client, user, tenant_factory, tenant_membership_factory):
+ tenant = tenant_factory(name="Tenant 1", type=TenantType.ORGANIZATION)
+ membership = tenant_membership_factory(tenant=tenant, user=user, role=TenantUserRole.MEMBER, is_accepted=False)
+ executed = self.mutate(
+ graphene_client, {"id": to_global_id("TenantMembershipType", membership.id), "token": "token"}
+ )
+ assert executed["errors"][0]["message"] == "permission_denied"
+
+ @classmethod
+ def mutate(cls, graphene_client, data):
+ return graphene_client.mutate(cls.MUTATION, variable_values={"input": data})
+
+
+class TestAllTenantsQuery:
+ def test_all_tenants_query(self, mocker, graphene_client, user_factory, tenant_factory, tenant_membership_factory):
+ query = """
+ query getAllTenants {
+ allTenants {
+ edges {
+ node {
+ id
+ name
+ slug
+ type
+ membership {
+ id
+ role
+ invitationAccepted
+ userId
+ inviteeEmailAddress
+ firstName
+ lastName
+ avatar
+ userEmail
+ invitationToken
+ }
+ userMemberships {
+ id
+ role
+ invitationAccepted
+ userId
+ inviteeEmailAddress
+ firstName
+ lastName
+ avatar
+ userEmail
+ invitationToken
+ }
+ }
+ }
+ }
+ }
+ """
+ user = user_factory(has_avatar=True)
+ tenant_factory.create_batch(10)
+ make_token = mocker.patch(
+ "apps.multitenancy.tokens.TenantInvitationTokenGenerator.make_token", return_value="token"
+ )
+ default_user_tenant = user.tenants.first()
+ tenant_with_invitation = tenant_factory(name="Invitation Tenant", type=TenantType.ORGANIZATION)
+ tenants = [
+ default_user_tenant,
+ tenant_with_invitation,
+ tenant_factory(name="Test tenant", type=TenantType.ORGANIZATION),
+ tenant_factory(name="Test tenant 2", type=TenantType.ORGANIZATION),
+ ]
+ default_user_tenant_membership = TenantMembership.objects.filter(user=user, tenant=default_user_tenant).first()
+ invitation_membership = tenant_membership_factory(
+ tenant=tenant_with_invitation, role=TenantUserRole.ADMIN, user=user, is_accepted=False
+ )
+ memberships = [
+ default_user_tenant_membership,
+ invitation_membership,
+ tenant_membership_factory(tenant=tenants[2], role=TenantUserRole.OWNER, user=user),
+ tenant_membership_factory(tenant=tenants[3], role=TenantUserRole.MEMBER, user=user),
+ ]
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(None, None)
+ executed = graphene_client.query(query)
+ executed_tenants = executed["data"]["allTenants"]["edges"]
+ for idx, executed_tenant in enumerate(executed_tenants):
+ assert executed_tenant["node"]["id"] == to_global_id("TenantType", tenants[idx].id)
+ assert executed_tenant["node"]["name"] == tenants[idx].name
+ assert executed_tenant["node"]["slug"] == tenants[idx].slug
+ assert executed_tenant["node"]["type"] == tenants[idx].type
+ assert executed_tenant["node"]["userMemberships"] is None
+ assert executed_tenant["node"]["membership"]["id"] == to_global_id(
+ "TenantMembershipType", memberships[idx].id
+ )
+ assert executed_tenant["node"]["membership"]["role"] == memberships[idx].role
+ assert executed_tenant["node"]["membership"]["invitationAccepted"] == memberships[idx].is_accepted
+ assert executed_tenant["node"]["membership"]["userId"] == user.id
+ assert executed_tenant["node"]["membership"]["firstName"] == user.profile.first_name
+ assert executed_tenant["node"]["membership"]["lastName"] == user.profile.last_name
+ assert executed_tenant["node"]["membership"]["userEmail"] == user.email
+ assert (
+ os.path.split(executed_tenant["node"]["membership"]["avatar"])[1]
+ == os.path.split(user.profile.avatar.thumbnail.name)[1]
+ )
+ if memberships[idx].is_accepted:
+ assert executed_tenant["node"]["membership"]["invitationToken"] is None
+ else:
+ assert executed_tenant["node"]["membership"]["invitationToken"] == "token"
+
+ make_token.assert_called_once()
+
+ def test_all_tenants_query_unauthenticated_user(self, graphene_client):
+ query = """
+ query getAllTenants {
+ allTenants {
+ edges {
+ node {
+ id
+ name
+ slug
+ type
+ membership {
+ id
+ role
+ invitationAccepted
+ userId
+ inviteeEmailAddress
+ firstName
+ lastName
+ avatar
+ userEmail
+ invitationToken
+ }
+ }
+ }
+ }
+ }
+ """
+ executed = graphene_client.query(query)
+ executed_tenants = executed["data"]["allTenants"]["edges"]
+ assert executed_tenants == []
+
+
+class TestTenantQuery:
+ def test_tenant_query(self, graphene_client, user_factory, tenant_factory, tenant_membership_factory):
+ query = """
+ query getTenant($id: ID!) {
+ tenant(id: $id) {
+ id
+ name
+ slug
+ type
+ membership {
+ id
+ role
+ invitationAccepted
+ userId
+ inviteeEmailAddress
+ firstName
+ lastName
+ avatar
+ userEmail
+ invitationToken
+ }
+ userMemberships {
+ id
+ role
+ invitationAccepted
+ userId
+ inviteeEmailAddress
+ invitationToken
+ firstName
+ lastName
+ avatar
+ userEmail
+ }
+ }
+ }
+ """
+ user = user_factory(has_avatar=True)
+ tenant_factory.create_batch(10)
+ tenant = tenant_factory(name="Test tenant", type=TenantType.ORGANIZATION)
+ membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.OWNER, user=user)
+ tenant_users = user_factory.create_batch(5)
+ for tenant_user in tenant_users:
+ tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, user=tenant_user)
+ tenant_invited_users = user_factory.create_batch(5)
+ for tenant_user in tenant_invited_users:
+ tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, user=tenant_user, is_accepted=False)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant=tenant, role=TenantUserRole.OWNER)
+ executed = graphene_client.query(query, variable_values={"id": to_global_id("TenantType", tenant.pk)})
+ executed_tenant = executed["data"]["tenant"]
+ assert executed_tenant["id"] == to_global_id("TenantType", tenant.id)
+ assert executed_tenant["name"] == tenant.name
+ assert executed_tenant["slug"] == tenant.slug
+ assert executed_tenant["type"] == tenant.type
+ assert len(executed_tenant["userMemberships"]) == 11
+ for user_membership in executed_tenant["userMemberships"]:
+ assert user_membership["invitationToken"] is None
+ assert executed_tenant["membership"]["id"] == to_global_id("TenantMembershipType", membership.id)
+ assert executed_tenant["membership"]["role"] == membership.role
+ assert executed_tenant["membership"]["invitationAccepted"] == membership.is_accepted
+ assert executed_tenant["membership"]["userId"] == user.id
+ assert executed_tenant["membership"]["firstName"] == user.profile.first_name
+ assert executed_tenant["membership"]["lastName"] == user.profile.last_name
+ assert executed_tenant["membership"]["userEmail"] == user.email
+ assert (
+ os.path.split(executed_tenant["membership"]["avatar"])[1]
+ == os.path.split(user.profile.avatar.thumbnail.name)[1]
+ )
+ assert executed_tenant["membership"]["invitationToken"] is None
+
+ def test_tenant_query_user_with_invitation(
+ self, mocker, graphene_client, user_factory, tenant_factory, tenant_membership_factory
+ ):
+ query = """
+ query getTenant($id: ID!) {
+ tenant(id: $id) {
+ id
+ name
+ slug
+ type
+ membership {
+ id
+ role
+ invitationAccepted
+ userId
+ inviteeEmailAddress
+ firstName
+ lastName
+ avatar
+ userEmail
+ invitationToken
+ }
+ userMemberships {
+ id
+ role
+ invitationAccepted
+ userId
+ inviteeEmailAddress
+ invitationToken
+ firstName
+ lastName
+ avatar
+ userEmail
+ }
+ }
+ }
+ """
+ user = user_factory(has_avatar=True)
+ tenant_factory.create_batch(10)
+ make_token = mocker.patch(
+ "apps.multitenancy.tokens.TenantInvitationTokenGenerator.make_token", return_value="token"
+ )
+ tenant = tenant_factory(name="Test tenant", type=TenantType.ORGANIZATION)
+ membership = tenant_membership_factory(tenant=tenant, role=TenantUserRole.OWNER, user=user, is_accepted=False)
+ tenant_users = user_factory.create_batch(5)
+ for tenant_user in tenant_users:
+ tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, user=tenant_user)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant=tenant, role=None)
+ executed = graphene_client.query(query, variable_values={"id": to_global_id("TenantType", tenant.pk)})
+ executed_tenant = executed["data"]["tenant"]
+ assert executed_tenant["id"] == to_global_id("TenantType", tenant.id)
+ assert executed_tenant["name"] == tenant.name
+ assert executed_tenant["slug"] == tenant.slug
+ assert executed_tenant["type"] == tenant.type
+ assert executed_tenant["userMemberships"] is None
+ assert executed_tenant["membership"]["id"] == to_global_id("TenantMembershipType", membership.id)
+ assert executed_tenant["membership"]["role"] == membership.role
+ assert executed_tenant["membership"]["invitationAccepted"] == membership.is_accepted
+ assert executed_tenant["membership"]["userId"] == user.id
+ assert executed_tenant["membership"]["firstName"] == user.profile.first_name
+ assert executed_tenant["membership"]["lastName"] == user.profile.last_name
+ assert executed_tenant["membership"]["userEmail"] == user.email
+ assert (
+ os.path.split(executed_tenant["membership"]["avatar"])[1]
+ == os.path.split(user.profile.avatar.thumbnail.name)[1]
+ )
+ assert executed_tenant["membership"]["invitationToken"] == "token"
+ # No permissions for userMemberships for not accepted invitation
+ assert executed["errors"][0]["message"] == "permission_denied"
+ make_token.assert_called_once()
+
+ def test_tenant_query_user_without_membership(
+ self, graphene_client, user_factory, tenant_factory, tenant_membership_factory
+ ):
+ query = """
+ query getTenant($id: ID!) {
+ tenant(id: $id) {
+ id
+ name
+ slug
+ type
+ membership {
+ id
+ role
+ invitationAccepted
+ userId
+ inviteeEmailAddress
+ firstName
+ lastName
+ avatar
+ userEmail
+ invitationToken
+ }
+ userMemberships {
+ id
+ role
+ invitationAccepted
+ userId
+ inviteeEmailAddress
+ invitationToken
+ firstName
+ lastName
+ avatar
+ userEmail
+ }
+ }
+ }
+ """
+ tenant_factory.create_batch(10)
+ tenant = tenant_factory(name="Test tenant", type=TenantType.ORGANIZATION)
+ tenant_users = user_factory.create_batch(5)
+ user = user_factory()
+ for tenant_user in tenant_users:
+ tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, user=tenant_user)
+ graphene_client.force_authenticate(user)
+ graphene_client.set_tenant_dependent_context(tenant=tenant, role=None)
+ executed = graphene_client.query(query, variable_values={"id": to_global_id("TenantType", tenant.pk)})
+ assert executed["data"]["tenant"] is None
+
+ def test_tenant_query_unauthenticated_user(
+ self, graphene_client, user_factory, tenant_factory, tenant_membership_factory
+ ):
+ query = """
+ query getTenant($id: ID!) {
+ tenant(id: $id) {
+ id
+ name
+ slug
+ type
+ membership {
+ id
+ role
+ invitationAccepted
+ userId
+ inviteeEmailAddress
+ firstName
+ lastName
+ avatar
+ userEmail
+ invitationToken
+ }
+ userMemberships {
+ id
+ role
+ invitationAccepted
+ userId
+ inviteeEmailAddress
+ invitationToken
+ firstName
+ lastName
+ avatar
+ userEmail
+ }
+ }
+ }
+ """
+ tenant_factory.create_batch(10)
+ tenant = tenant_factory(name="Test tenant", type=TenantType.ORGANIZATION)
+ tenant_users = user_factory.create_batch(5)
+ for tenant_user in tenant_users:
+ tenant_membership_factory(tenant=tenant, role=TenantUserRole.MEMBER, user=tenant_user)
+ executed = graphene_client.query(query, variable_values={"id": to_global_id("TenantType", tenant.pk)})
+ assert executed["errors"][0]["message"] == "permission_denied"
diff --git a/packages/backend/apps/multitenancy/tests/test_serializers.py b/packages/backend/apps/multitenancy/tests/test_serializers.py
new file mode 100644
index 000000000..250fc27a1
--- /dev/null
+++ b/packages/backend/apps/multitenancy/tests/test_serializers.py
@@ -0,0 +1,93 @@
+import pytest
+from unittest.mock import Mock
+
+from ..models import TenantMembership
+from ..constants import TenantUserRole, TenantType
+from ..serializers import CreateTenantInvitationSerializer
+
+
+pytestmark = pytest.mark.django_db
+
+
+class TestCreateTenantInvitationSerializer:
+ def test_create_invitation_existing_user(self, mocker, user, user_factory, tenant_factory):
+ make_token = mocker.patch(
+ "apps.multitenancy.tokens.TenantInvitationTokenGenerator.make_token", return_value="token"
+ )
+ creator = user_factory()
+
+ tenant = tenant_factory(name="Test Tenant", type=TenantType.ORGANIZATION)
+
+ data = {
+ "email": user.email,
+ "role": TenantUserRole.ADMIN,
+ "tenant_id": str(tenant.id),
+ }
+ serializer = CreateTenantInvitationSerializer(data=data, context={'request': Mock(tenant=tenant, user=creator)})
+ assert serializer.is_valid()
+
+ result = serializer.create(serializer.validated_data)
+
+ assert result['ok']
+ assert (
+ TenantMembership.objects.get_not_accepted()
+ .filter(user=user, tenant=tenant, role=TenantUserRole.ADMIN)
+ .exists()
+ )
+ make_token.assert_called_once()
+
+ def test_create_invitation_new_user(self, mocker, user_factory, tenant_factory):
+ make_token = mocker.patch(
+ "apps.multitenancy.tokens.TenantInvitationTokenGenerator.make_token", return_value="token"
+ )
+ creator = user_factory()
+ tenant = tenant_factory(name="Test Tenant", type=TenantType.ORGANIZATION)
+
+ data = {
+ "email": "new_user@example.com",
+ "role": TenantUserRole.MEMBER,
+ "tenant_id": str(tenant.id),
+ }
+ serializer = CreateTenantInvitationSerializer(data=data, context={'request': Mock(tenant=tenant, user=creator)})
+ assert serializer.is_valid()
+
+ result = serializer.create(serializer.validated_data)
+
+ assert result['ok']
+ assert (
+ TenantMembership.objects.get_not_accepted()
+ .filter(
+ invitee_email_address='new_user@example.com',
+ tenant=tenant,
+ role=TenantUserRole.MEMBER,
+ user__isnull=True,
+ )
+ .exists()
+ )
+ make_token.assert_called_once()
+
+ def test_create_invitation_for_default_tenant_new_user(self, tenant_factory):
+ tenant = tenant_factory(name="Test Tenant", type=TenantType.DEFAULT)
+
+ data = {
+ "email": "new_user@example.com",
+ "role": TenantUserRole.MEMBER,
+ "tenant_id": str(tenant.id),
+ }
+ serializer = CreateTenantInvitationSerializer(data=data, context={'request': Mock(tenant=tenant)})
+ assert not serializer.is_valid()
+ assert "Invitation for personal tenant cannot be created." in serializer.errors['non_field_errors'][0]
+
+ def test_create_invitation_existing_user_duplicate(self, user, tenant_factory):
+ tenant = tenant_factory(name="Test Tenant", type=TenantType.ORGANIZATION)
+ TenantMembership.objects.create(user=user, tenant=tenant, role=TenantUserRole.ADMIN)
+
+ data = {
+ "email": user.email,
+ "role": TenantUserRole.MEMBER,
+ "tenant_id": str(tenant.id),
+ }
+ serializer = CreateTenantInvitationSerializer(data=data, context={'request': Mock(tenant=tenant)})
+
+ assert not serializer.is_valid()
+ assert 'Invitation already exists' in serializer.errors['non_field_errors'][0]
diff --git a/packages/backend/apps/multitenancy/tests/test_tokens.py b/packages/backend/apps/multitenancy/tests/test_tokens.py
new file mode 100644
index 000000000..88303cb3b
--- /dev/null
+++ b/packages/backend/apps/multitenancy/tests/test_tokens.py
@@ -0,0 +1,47 @@
+import pytest
+
+from datetime import datetime, timedelta, timezone
+from django.conf import settings
+from ..tokens import tenant_invitation_token
+
+
+pytestmark = pytest.mark.django_db
+
+
+class TestTenantInvitationTokenGenerator:
+ def test_make_token(self, user, tenant_membership):
+ token = tenant_invitation_token.make_token(user.email, tenant_membership)
+ assert isinstance(token, str)
+
+ def test_check_token_valid(self, user, tenant_membership):
+ token = tenant_invitation_token.make_token(user.email, tenant_membership)
+ assert tenant_invitation_token.check_token(user.email, token, tenant_membership)
+
+ def test_check_token_invalid_email(self, user, tenant_membership):
+ token = tenant_invitation_token.make_token(user.email, tenant_membership)
+ invalid_email = "invalid@example.com"
+ assert not tenant_invitation_token.check_token(invalid_email, token, tenant_membership)
+
+ def test_check_token_expired(self, mocker, user, tenant_membership):
+ # Reduce timeout for testing purposes
+ settings.TENANT_INVITATION_TIMEOUT = 1
+ token = tenant_invitation_token.make_token(user.email, tenant_membership)
+
+ # Sleep for more than the timeout
+ expired_date = datetime.now(tz=timezone.utc) + timedelta(seconds=2)
+ with mocker.patch.object(tenant_invitation_token, "_now", return_value=expired_date):
+ assert not tenant_invitation_token.check_token(user.email, token, tenant_membership)
+ # Restore original timeout value
+ settings.TENANT_INVITATION_TIMEOUT = 2 * 24 * 3600
+
+ def test_check_token_tampered(self, user, tenant_membership):
+ token = tenant_invitation_token.make_token(user.email, tenant_membership)
+
+ # Tamper with the token
+ tampered_token = token.replace(token[0], token[2])
+
+ assert not tenant_invitation_token.check_token(user.email, tampered_token, tenant_membership)
+
+ def test_check_token_invalid_format(self, user, tenant_membership):
+ invalid_token = "invalid_token_format"
+ assert not tenant_invitation_token.check_token(user.email, invalid_token, tenant_membership)
diff --git a/packages/backend/apps/multitenancy/tokens.py b/packages/backend/apps/multitenancy/tokens.py
new file mode 100644
index 000000000..4e84fa8e9
--- /dev/null
+++ b/packages/backend/apps/multitenancy/tokens.py
@@ -0,0 +1,81 @@
+import six
+
+from datetime import datetime, timezone
+from django.conf import settings
+from django.utils.crypto import constant_time_compare, salted_hmac
+from django.utils.http import base36_to_int, int_to_base36
+from django.contrib.auth.tokens import PasswordResetTokenGenerator
+
+
+class TenantInvitationTokenGenerator(PasswordResetTokenGenerator):
+ def _make_hash_value(self, user_email, timestamp, tenant_membership_pk):
+ return six.text_type(user_email) + six.text_type(timestamp) + six.text_type(str(tenant_membership_pk))
+
+ def _make_token_with_timestamp(self, user_email, timestamp, tenant_membership_pk, secret):
+ # timestamp is number of seconds since 2001-1-1. Converted to base 36,
+ # this gives us a 6 digit string until about 2069.
+ ts_b36 = int_to_base36(timestamp)
+ hash_string = salted_hmac(
+ self.key_salt,
+ self._make_hash_value(user_email, timestamp, tenant_membership_pk),
+ secret=secret,
+ algorithm=self.algorithm,
+ ).hexdigest()[
+ ::2
+ ] # Limit to shorten the URL.
+ return "%s-%s" % (ts_b36, hash_string)
+
+ def make_token(self, user_email, tenant_membership):
+ """
+ Return a token that can be used once to make action on tenant invitation
+ for the given user.
+ """
+ return self._make_token_with_timestamp(
+ user_email,
+ self._num_seconds(tenant_membership.created_at),
+ tenant_membership.pk,
+ self.secret,
+ )
+
+ def _num_seconds(self, dt):
+ return int((dt - datetime(2001, 1, 1, tzinfo=timezone.utc)).total_seconds())
+
+ def _now(self):
+ # Used for mocking in tests
+ return datetime.now(tz=timezone.utc)
+
+ def check_token(self, user_email, token, tenant_membership):
+ """
+ Check that a tenant invitation token is correct for a given user.
+ """
+ if not (user_email and token):
+ return False
+ # Parse the token
+ try:
+ ts_b36, _ = token.split("-")
+ except ValueError:
+ return False
+
+ try:
+ ts = base36_to_int(ts_b36)
+ except ValueError:
+ return False
+
+ # Check that the timestamp/uid has not been tampered with
+ for secret in [self.secret, *self.secret_fallbacks]:
+ if constant_time_compare(
+ self._make_token_with_timestamp(user_email, ts, tenant_membership.pk, secret),
+ token,
+ ):
+ break
+ else:
+ return False
+
+ # Check the timestamp is within limit.
+ if (self._num_seconds(self._now()) - ts) > settings.TENANT_INVITATION_TIMEOUT:
+ return False
+
+ return True
+
+
+tenant_invitation_token = TenantInvitationTokenGenerator()
diff --git a/packages/backend/apps/users/management/commands/init_customers_plans.py b/packages/backend/apps/users/management/commands/init_customers_plans.py
index 21103a53d..9d11dd615 100644
--- a/packages/backend/apps/users/management/commands/init_customers_plans.py
+++ b/packages/backend/apps/users/management/commands/init_customers_plans.py
@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
-from ...models import User
+from apps.multitenancy.models import Tenant
from apps.finances.services import subscriptions
@@ -8,6 +8,6 @@ class Command(BaseCommand):
help = 'Creates stripe customer and schedule subscription plan'
def handle(self, *args, **options):
- users = User.objects.filter(djstripe_customers__isnull=True, is_superuser=False)
- for user in users:
- subscriptions.initialize_user(user=user)
+ tenants = Tenant.objects.filter(djstripe_customers__isnull=True)
+ for tenant in tenants:
+ subscriptions.initialize_tenant(tenant=tenant)
diff --git a/packages/backend/apps/users/models.py b/packages/backend/apps/users/models.py
index 3bf4b7bef..ee075e093 100644
--- a/packages/backend/apps/users/models.py
+++ b/packages/backend/apps/users/models.py
@@ -6,6 +6,7 @@
from common.acl.helpers import CommonGroups
from common.models import ImageWithThumbnailMixin
from common.storages import UniqueFilePathGenerator, PublicS3Boto3StorageWithCDN
+from apps.multitenancy.models import TenantMembership
class UserManager(BaseUserManager):
@@ -13,8 +14,9 @@ def create_user(self, email, password=None):
if not email:
raise ValueError("Users must have an email address")
+ normalized_email = self.normalize_email(email)
user = self.model(
- email=self.normalize_email(email),
+ email=normalized_email,
)
user.set_password(password)
user_group = Group.objects.get(name=CommonGroups.User)
@@ -24,6 +26,8 @@ def create_user(self, email, password=None):
UserProfile.objects.create(user=user)
+ TenantMembership.objects.associate_invitations_with_user(normalized_email, user)
+
return user
def create_superuser(self, email, password):
diff --git a/packages/backend/apps/users/schema.py b/packages/backend/apps/users/schema.py
index e789aed37..dc86cce4c 100644
--- a/packages/backend/apps/users/schema.py
+++ b/packages/backend/apps/users/schema.py
@@ -9,6 +9,8 @@
from common.graphql import mutations
from common.graphql import ratelimit
from common.graphql.acl.decorators import permission_classes
+from apps.multitenancy.models import Tenant
+from apps.multitenancy.schema import TenantType
from . import models
from . import serializers
from .services.users import get_user_from_resolver, get_role_names, get_user_avatar_url
@@ -134,11 +136,12 @@ class CurrentUserType(DjangoObjectType):
first_name = graphene.String()
last_name = graphene.String()
roles = graphene.List(of_type=graphene.String)
+ tenants = graphene.List(of_type=TenantType)
avatar = graphene.String()
class Meta:
model = models.User
- fields = ("id", "email", "first_name", "last_name", "roles", "avatar", "otp_enabled", "otp_verified")
+ fields = ("id", "email", "first_name", "last_name", "roles", "avatar", "otp_enabled", "otp_verified", "tenants")
@staticmethod
def resolve_first_name(parent, info):
@@ -156,6 +159,14 @@ def resolve_roles(parent, info):
def resolve_avatar(parent, info):
return get_user_avatar_url(get_user_from_resolver(info))
+ @staticmethod
+ def resolve_tenants(parent, info):
+ user = get_user_from_resolver(info)
+ tenants = user.tenants.all()
+ if not len(tenants):
+ Tenant.objects.get_or_create_user_default_tenant(user)
+ return tenants
+
class UserProfileType(DjangoObjectType):
class Meta:
diff --git a/packages/backend/apps/users/serializers.py b/packages/backend/apps/users/serializers.py
index afd683909..461d632d1 100644
--- a/packages/backend/apps/users/serializers.py
+++ b/packages/backend/apps/users/serializers.py
@@ -17,6 +17,8 @@
from .services import otp as otp_services
from .utils import generate_otp_auth_token
+from apps.multitenancy.models import Tenant
+
UPLOADED_AVATAR_SIZE_LIMIT = 1 * 1024 * 1024
@@ -86,6 +88,9 @@ def create(self, validated_data):
user=user, data={'user_id': user.id.hashid, 'token': tokens.account_activation_token.make_token(user)}
).send()
+ # Create user signup tenant
+ Tenant.objects.get_or_create_user_default_tenant(user)
+
return {'id': user.id, 'email': user.email, 'access': str(refresh.access_token), 'refresh': str(refresh)}
diff --git a/packages/backend/apps/users/tests/factories.py b/packages/backend/apps/users/tests/factories.py
index 755a42557..eb3d6e7d8 100644
--- a/packages/backend/apps/users/tests/factories.py
+++ b/packages/backend/apps/users/tests/factories.py
@@ -8,6 +8,8 @@
from djstripe.models import Customer
from common.acl.helpers import CommonGroups
+from apps.multitenancy.models import Tenant
+from apps.multitenancy.tests.factories import TenantFactory
class GroupFactory(factory.django.DjangoModelFactory):
@@ -47,6 +49,13 @@ def groups(self, create: bool, extracted: Optional[List[str]], **kwargs):
group_names = extracted or [CommonGroups.User]
self.groups.add(*[Group.objects.get_or_create(name=group_name)[0] for group_name in group_names])
+ @factory.post_generation
+ def sign_up_tenant(self, create: bool, extracted: Optional[List[str]], **kwargs):
+ if not create:
+ return
+
+ Tenant.objects.get_or_create_user_default_tenant(self)
+
@factory.post_generation
def admin(self, create, extracted, **kwargs):
if extracted is None:
@@ -98,4 +107,4 @@ class StripeCustomerFactory(factory.django.DjangoModelFactory):
class Meta:
model = Customer
- subscriber = factory.SubFactory(UserFactory, profile=None)
+ subscriber = factory.SubFactory(TenantFactory)
diff --git a/packages/backend/apps/users/tests/test_commands.py b/packages/backend/apps/users/tests/test_commands.py
index c2322d56d..14adc5897 100644
--- a/packages/backend/apps/users/tests/test_commands.py
+++ b/packages/backend/apps/users/tests/test_commands.py
@@ -8,21 +8,14 @@
class TestInitCustomerCommand:
+ # NOTE: TenantFactory triggers UserFactory which generates default tenant for user. Mocks are called twice.
@factory.django.mute_signals(signals.post_save)
- def test_command_run_for_users_without_customer(self, stripe_customer_factory, user_factory, mocker):
- mock = mocker.patch("apps.finances.services.subscriptions.initialize_user")
- user = user_factory.create()
- stripe_customer_factory.create()
+ def test_command_run_for_users_without_customer(self, tenant_factory, mocker):
+ mock = mocker.patch("apps.finances.services.subscriptions.initialize_tenant")
+ tenant = tenant_factory.create()
Command().handle()
- mock.assert_called_once_with(user=user)
+ _, last_call_kwargs = mock.call_args_list[-1]
- @factory.django.mute_signals(signals.post_save)
- def test_command_do_not_run_for_superusers(self, user_factory, mocker):
- mock = mocker.patch("apps.finances.services.subscriptions.initialize_user")
- user_factory.create(is_superuser=True)
-
- Command().handle()
-
- mock.assert_not_called()
+ assert last_call_kwargs == {"tenant": tenant}
diff --git a/packages/backend/apps/users/tests/test_schema.py b/packages/backend/apps/users/tests/test_schema.py
index 357c4923c..cf16ad42b 100644
--- a/packages/backend/apps/users/tests/test_schema.py
+++ b/packages/backend/apps/users/tests/test_schema.py
@@ -10,6 +10,7 @@
from rest_framework_simplejwt.tokens import RefreshToken, BlacklistedToken, AccessToken
from .. import models, tokens
from ..utils import generate_otp_auth_token
+from apps.multitenancy.constants import TenantType
pytestmark = pytest.mark.django_db
@@ -35,6 +36,12 @@ class TestSignup:
}
'''
+ @staticmethod
+ def _run_correct_sing_up_mutation(graphene_client, faker):
+ return graphene_client.mutate(
+ TestSignup.MUTATION, variable_values={'input': {'email': faker.email(), 'password': faker.password()}}
+ )
+
def test_return_error_with_missing_email(self, graphene_client, faker):
password = faker.password()
executed = graphene_client.mutate(self.MUTATION, variable_values={'input': {'password': password}})
@@ -69,21 +76,23 @@ def test_create_user(self, graphene_client, faker):
assert models.User.objects.get(email=email)
def test_create_user_profile_instance(self, graphene_client, faker):
- executed = graphene_client.mutate(
- self.MUTATION, variable_values={'input': {'email': faker.email(), 'password': faker.password()}}
- )
-
+ executed = TestSignup._run_correct_sing_up_mutation(graphene_client, faker)
user = models.User.objects.get(id=executed['data']['signUp']["id"])
+
assert user.profile
def test_add_to_user_group(self, graphene_client, faker):
- executed = graphene_client.mutate(
- self.MUTATION, variable_values={'input': {'email': faker.email(), 'password': faker.password()}}
- )
-
+ executed = TestSignup._run_correct_sing_up_mutation(graphene_client, faker)
user = models.User.objects.get(id=executed['data']['signUp']["id"])
+
assert user.has_group(CommonGroups.User)
+ def test_add_user_signup_tenant(self, graphene_client, faker):
+ executed = TestSignup._run_correct_sing_up_mutation(graphene_client, faker)
+ user = models.User.objects.get(id=executed['data']['signUp']["id"])
+
+ assert user.tenants.count()
+
class TestObtainToken:
MUTATION = '''
@@ -148,6 +157,16 @@ def test_response_data(self, graphene_client, user_factory, user_avatar_factory)
avatar
otpEnabled
otpVerified
+ tenants {
+ id
+ name
+ slug
+ type
+ membership{
+ role
+ invitationToken
+ }
+ }
}
}
'''
@@ -173,6 +192,48 @@ def test_response_data(self, graphene_client, user_factory, user_avatar_factory)
), data["avatar"]
assert data["otpEnabled"] == user.otp_enabled
assert data["otpVerified"] == user.otp_verified
+ assert len(data["tenants"]) > 0
+ assert data["tenants"][0]["name"] == "test@example.com"
+ assert data["tenants"][0]["membership"]["role"] == "OWNER"
+ assert data["tenants"][0]["type"] == "default"
+ assert data["tenants"][0]["membership"]["invitationToken"] is None
+
+ def test_response_data_invitation_token_active_invitation(
+ self, graphene_client, user_factory, tenant_factory, tenant_membership_factory
+ ):
+ query = '''
+ query {
+ currentUser {
+ tenants {
+ type
+ membership{
+ id
+ role
+ invitationAccepted
+ invitationToken
+ }
+ }
+ }
+ }
+ '''
+ user = user_factory(
+ has_avatar=True,
+ email="test@example.com",
+ profile__first_name="Grzegorz",
+ profile__last_name="Brzęczyszczykiewicz",
+ groups=[CommonGroups.User, CommonGroups.Admin],
+ )
+ tenant = tenant_factory(type=TenantType.ORGANIZATION)
+ tenant_membership_factory(is_accepted=False, user=user, tenant=tenant)
+ graphene_client.force_authenticate(user)
+
+ executed = graphene_client.query(query)
+ data = executed["data"]["currentUser"]
+ for tenant in data["tenants"]:
+ if tenant["type"] == "default":
+ assert tenant["membership"]["invitationToken"] is None
+ else:
+ assert tenant["membership"]["invitationToken"] is not None
def test_not_authenticated(self, graphene_client):
executed = graphene_client.query(
diff --git a/packages/backend/apps/users/tests/test_signals.py b/packages/backend/apps/users/tests/test_signals.py
index 27b89e429..eca528366 100644
--- a/packages/backend/apps/users/tests/test_signals.py
+++ b/packages/backend/apps/users/tests/test_signals.py
@@ -5,20 +5,23 @@
class TestUserPostSaveSignal:
- def test_signal_is_not_raising_exception_on_auth_error(self, user_factory, mocker):
- mock = mocker.patch("apps.finances.services.subscriptions.initialize_user", side_effect=AuthenticationError())
+ # NOTE: TenantFactory triggers UserFactory which generates default tenant for user. Mocks are called twice.
+ def test_signal_is_not_raising_exception_on_auth_error(self, tenant_factory, mocker):
+ mock = mocker.patch("apps.finances.services.subscriptions.initialize_tenant", side_effect=AuthenticationError())
sentry_mock = mocker.patch("apps.finances.signals.logger.error")
- user_factory.create()
+ tenant = tenant_factory.create()
- mock.assert_called_once()
- sentry_mock.assert_called_once()
+ _, last_call_kwargs = mock.call_args_list[-1]
- def test_reraise_stripe_error(self, user_factory, totp_mock, mocker):
+ assert last_call_kwargs == {"tenant": tenant}
+ assert sentry_mock.call_count == 2
+
+ def test_reraise_stripe_error(self, tenant_factory, totp_mock, mocker):
initial_error = Exception
- mocker.patch("apps.finances.services.subscriptions.initialize_user", side_effect=initial_error())
+ mocker.patch("apps.finances.services.subscriptions.initialize_tenant", side_effect=initial_error())
with pytest.raises(initial_error) as error:
- user_factory.create()
+ tenant_factory.create()
assert initial_error == error.type
diff --git a/packages/backend/common/acl/helpers.py b/packages/backend/common/acl/helpers.py
index 61509dceb..9cdde5150 100644
--- a/packages/backend/common/acl/helpers.py
+++ b/packages/backend/common/acl/helpers.py
@@ -1,3 +1,6 @@
+from apps.multitenancy.constants import TenantUserRole
+
+
def make_statement(action, effect, principal, condition=None):
statement = {
'action': action,
@@ -47,3 +50,9 @@ def id(name):
@staticmethod
def group(name):
return f'group:{name}'
+
+
+class TenantRoles:
+ Owner = [TenantUserRole.OWNER]
+ Admin = [TenantUserRole.OWNER, TenantUserRole.ADMIN]
+ Member = [TenantUserRole.OWNER, TenantUserRole.ADMIN, TenantUserRole.MEMBER]
diff --git a/packages/backend/common/acl/policies.py b/packages/backend/common/acl/policies.py
index 489d081f0..92cf8c12c 100644
--- a/packages/backend/common/acl/policies.py
+++ b/packages/backend/common/acl/policies.py
@@ -1,6 +1,6 @@
from rest_access_policy import AccessPolicy
-from .helpers import make_statement, Action, Effect, Principal, CommonGroups
+from .helpers import make_statement, Action, Effect, Principal, CommonGroups, TenantRoles
class AdminFullAccess(AccessPolicy):
@@ -21,3 +21,47 @@ class IsAuthenticatedFullAccess(AccessPolicy):
class AnyoneFullAccess(AccessPolicy):
statements = [make_statement(principal=Principal.Any, action=Action.Any, effect=Effect.Allow)]
+
+
+class TenantDependentAccess(AccessPolicy):
+ def is_request_from_tenant_owner(self, request, view, action) -> bool:
+ return request.user_role in TenantRoles.Owner
+
+ def is_request_from_tenant_admin(self, request, view, action) -> bool:
+ return request.user_role in TenantRoles.Admin
+
+ def is_request_from_tenant_member(self, request, view, action) -> bool:
+ return request.user_role in TenantRoles.Member
+
+
+class IsTenantOwnerAccess(TenantDependentAccess):
+ statements = [
+ make_statement(
+ principal=Principal.Authenticated,
+ action=Action.Any,
+ effect=Effect.Allow,
+ condition=["is_request_from_tenant_owner"],
+ )
+ ]
+
+
+class IsTenantAdminAccess(TenantDependentAccess):
+ statements = [
+ make_statement(
+ principal=Principal.Authenticated,
+ action=Action.Any,
+ effect=Effect.Allow,
+ condition=["is_request_from_tenant_admin"],
+ )
+ ]
+
+
+class IsTenantMemberAccess(TenantDependentAccess):
+ statements = [
+ make_statement(
+ principal=Principal.Authenticated,
+ action=Action.Any,
+ effect=Effect.Allow,
+ condition=["is_request_from_tenant_member"],
+ )
+ ]
diff --git a/packages/backend/common/graphql/field_conversions.py b/packages/backend/common/graphql/field_conversions.py
index d0ea0cc85..b9a9e7491 100644
--- a/packages/backend/common/graphql/field_conversions.py
+++ b/packages/backend/common/graphql/field_conversions.py
@@ -25,3 +25,14 @@ def convert_models_file_field_to_file_field_type(field, registry=None):
@get_graphene_type_from_serializer_field.register(serializers.FileField)
def convert_serializers_file_field_to_upload(field):
return Upload
+
+
+class TextChoicesFieldType(serializers.ChoiceField):
+ def __init__(self, choices, choices_class, **kwargs):
+ self.choices_class = choices_class
+ super().__init__(choices, **kwargs)
+
+
+@get_graphene_type_from_serializer_field.register(TextChoicesFieldType)
+def convert_serializer_field_to_enum(field):
+ return graphene.Enum.from_enum(field.choices_class)
diff --git a/packages/backend/common/graphql/mutations.py b/packages/backend/common/graphql/mutations.py
index f7f683e6e..8ba61e25a 100644
--- a/packages/backend/common/graphql/mutations.py
+++ b/packages/backend/common/graphql/mutations.py
@@ -615,7 +615,7 @@ def get_object(cls, id, **kwargs):
return get_object_or_404(model, pk=pk, **kwargs)
@classmethod
- def mutate_and_get_payload(cls, root, info, id):
+ def mutate_and_get_payload(cls, root, info, id, **kwargs):
"""
Perform a mutation to delete a model instance
@@ -627,3 +627,68 @@ def mutate_and_get_payload(cls, root, info, id):
obj = cls.get_object(id)
obj.delete()
return cls(deleted_ids=[id])
+
+
+class DeleteTenantDependentModelMutation(DeleteModelMutation):
+ """
+ `DeleteTenantDependentModelMutation` is a mutation class that inherits from
+ [`DeleteModelMutation`](#deletemodelmutation).
+ It is used to delete an object of a specified model from the database which is dependent on tenant.
+ It implements `tenant_id` field in input.
+ """
+
+ class Meta:
+ abstract = True
+
+ class Input:
+ id = graphene.String()
+ tenant_id = graphene.String(required=True)
+
+ @classmethod
+ def mutate_and_get_payload(cls, root, info, **input):
+ if "tenant_id" in input:
+ _, input["tenant_id"] = from_global_id(input["tenant_id"])
+ return super().mutate_and_get_payload(root, info, **input)
+
+
+class UpdateTenantDependentModelMutation(UpdateModelMutation):
+ """
+ `UpdateTenantDependentModelMutation` is a mutation class that inherits from
+ [`UpdateModelMutation`](#updatemodelmutation).
+ It is used to update an object of a specified model in the database which is dependent on tenant.
+ It implements `tenant_id` field in input.
+ """
+
+ class Meta:
+ abstract = True
+
+ class Input:
+ id = graphene.String()
+ tenant_id = graphene.String(required=True)
+
+ @classmethod
+ def mutate_and_get_payload(cls, root, info, **input):
+ if "tenant_id" in input:
+ _, input["tenant_id"] = from_global_id(input["tenant_id"])
+ return super().mutate_and_get_payload(root, info, **input)
+
+
+class CreateTenantDependentModelMutation(CreateModelMutation):
+ """
+ `CreateTenantDependentModelMutation` is a Relay mutation class that inherits from
+ [`CreateModelMutation`](#createmodelmutation).
+ It is used to create a new object of a specified model in the database which is dependent on tenant.
+ It implements `tenant_id` field in input.
+ """
+
+ class Meta:
+ abstract = True
+
+ class Input:
+ tenant_id = graphene.String(required=True)
+
+ @classmethod
+ def mutate_and_get_payload(cls, root, info, **input):
+ if "tenant_id" in input:
+ _, input["tenant_id"] = from_global_id(input["tenant_id"])
+ return super().mutate_and_get_payload(root, info, **input)
diff --git a/packages/backend/common/models.py b/packages/backend/common/models.py
index 40f073f8f..fd12949ee 100644
--- a/packages/backend/common/models.py
+++ b/packages/backend/common/models.py
@@ -1,3 +1,4 @@
+from django.db import models
from django.core.files.base import ContentFile
from io import BytesIO
from common.graphql import exceptions as graphql_exceptions
@@ -31,3 +32,29 @@ def save(self, *args, **kwargs):
if self.original:
self.make_thumbnail()
super().save(*args, **kwargs)
+
+
+class TimestampedMixin(models.Model):
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ abstract = True
+
+
+class TenantDependentModelMixin(models.Model):
+ """
+ A mixin class for models that are dependent on a specific tenant.
+ """
+
+ tenant = models.ForeignKey(
+ to="multitenancy.Tenant",
+ on_delete=models.CASCADE,
+ related_name="%(class)s_set",
+ verbose_name="Tenant",
+ blank=True,
+ null=True,
+ )
+
+ class Meta:
+ abstract = True
diff --git a/packages/backend/common/tests/test_graphql/test_acl.py b/packages/backend/common/tests/test_graphql/test_acl.py
index 3c52b0944..849fde601 100644
--- a/packages/backend/common/tests/test_graphql/test_acl.py
+++ b/packages/backend/common/tests/test_graphql/test_acl.py
@@ -158,39 +158,39 @@ def resolve_crud_demo_item_by_id(self, info, id):
class TestPermissionClassesForMutationField:
- def test_global_policy(self, graphene_client):
+ def test_global_policy(self, graphene_client, tenant):
Mutation = self.create_mutation()
schema = create_mutation_schema(Mutation)
graphene_client.schema = schema
- executed = self.call_mutation(graphene_client)
+ executed = self.call_mutation(graphene_client, tenant)
assert executed["errors"][0]["message"] == "permission_denied", executed
- def test_override_global_policy(self, graphene_client, user):
+ def test_override_global_policy(self, graphene_client, user, tenant):
Mutation = self.create_mutation(mutation_policies=(policies.AdminFullAccess,))
schema = create_mutation_schema(Mutation)
graphene_client.schema = schema
graphene_client.force_authenticate(user)
- executed = self.call_mutation(graphene_client)
+ executed = self.call_mutation(graphene_client, tenant)
assert executed["errors"][0]["message"] == "permission_denied"
- def test_global_policy_by_authenticated_user(self, graphene_client, user):
+ def test_global_policy_by_authenticated_user(self, graphene_client, user, tenant):
Mutation = self.create_mutation()
schema = create_mutation_schema(Mutation)
graphene_client.schema = schema
graphene_client.force_authenticate(user)
- executed = self.call_mutation(graphene_client)
+ executed = self.call_mutation(graphene_client, tenant)
assert executed['data']['createCrudDemoItem']
assert executed['data']['createCrudDemoItem']['crudDemoItem']
assert executed['data']['createCrudDemoItem']['crudDemoItem']['name'] == 'Item name'
@staticmethod
- def call_mutation(client):
+ def call_mutation(client, tenant):
return client.mutate(
"""
mutation ($input: TestCreateCrudDemoItemMutationInput!){
@@ -202,7 +202,7 @@ def call_mutation(client):
}
}
""",
- variable_values={"input": {'name': 'Item name'}},
+ variable_values={"input": {'name': 'Item name', "tenantId": to_global_id("TenantType", tenant.id)}},
)
@staticmethod
@@ -217,7 +217,7 @@ class TestCrudDemoItemConnection(graphene.Connection):
class Meta:
node = TestCrudDemoItemType
- class TestCreateCrudDemoItemMutation(mutations.CreateModelMutation):
+ class TestCreateCrudDemoItemMutation(mutations.CreateTenantDependentModelMutation):
class Meta:
serializer_class = serializers.CrudDemoItemSerializer
edge_class = TestCrudDemoItemConnection.Edge
diff --git a/packages/backend/config/schema.py b/packages/backend/config/schema.py
index 32acc05ab..8b00bf134 100644
--- a/packages/backend/config/schema.py
+++ b/packages/backend/config/schema.py
@@ -5,19 +5,31 @@
from apps.notifications import schema as notifications_schema
from apps.users import schema as users_schema
from apps.integrations import schema as integrations_schema
+from apps.multitenancy import schema as multitenancy_schema
from common.graphql.utils import graphql_query, graphql_mutation, graphql_subscription
schema = graphene.Schema(
- query=graphql_query([demo_schema.Query, notifications_schema.Query, users_schema.Query, finances_schema.Query]),
+ query=graphql_query(
+ [
+ demo_schema.Query,
+ notifications_schema.Query,
+ users_schema.Query,
+ finances_schema.Query,
+ multitenancy_schema.Query,
+ ]
+ ),
mutation=graphql_mutation(
[
demo_schema.Mutation,
+ demo_schema.TenantMemberMutation,
notifications_schema.Mutation,
users_schema.AnyoneMutation,
users_schema.AuthenticatedMutation,
users_schema.Mutation,
finances_schema.Mutation,
integrations_schema.Mutation,
+ multitenancy_schema.Mutation,
+ multitenancy_schema.TenantOwnerMutation,
]
),
subscription=graphql_subscription(([notifications_schema.Subscription])),
diff --git a/packages/backend/config/settings.py b/packages/backend/config/settings.py
index 6a6444071..a01b55d5e 100644
--- a/packages/backend/config/settings.py
+++ b/packages/backend/config/settings.py
@@ -65,6 +65,7 @@
"apps.notifications",
"apps.websockets",
"apps.integrations",
+ "apps.multitenancy",
]
INSTALLED_APPS = (
@@ -312,6 +313,14 @@
STRIPE_LIVE_MODE = env.bool("STRIPE_LIVE_MODE", default=False)
DJSTRIPE_WEBHOOK_SECRET = env("DJSTRIPE_WEBHOOK_SECRET", default="")
DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id"
+
+
+def tenant_request_callback(request):
+ return request.tenant
+
+
+DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK = tenant_request_callback
+DJSTRIPE_SUBSCRIBER_MODEL = "multitenancy.Tenant"
# Disable stripe checks for keys on django application start
STRIPE_CHECKS_ENABLED = env.bool("STRIPE_CHECKS_ENABLED", default=True)
if not STRIPE_CHECKS_ENABLED:
@@ -324,7 +333,7 @@
GRAPHENE = {
"SCHEMA": "config.schema.schema",
"DEFAULT_PERMISSION_CLASSES": ("common.acl.policies.IsAuthenticatedFullAccess",),
- "MIDDLEWARE": ["common.middleware.SentryMiddleware"],
+ "MIDDLEWARE": ["common.middleware.SentryMiddleware", "apps.multitenancy.middleware.TenantUserRoleMiddleware"],
}
NOTIFICATIONS_STRATEGIES = ["InAppNotificationStrategy"]
@@ -361,3 +370,5 @@
UPLOADED_DOCUMENT_SIZE_LIMIT = env.int("UPLOADED_DOCUMENT_SIZE_LIMIT", default=10 * 1024 * 1024)
USER_DOCUMENTS_NUMBER_LIMIT = env.int("USER_DOCUMENTS_NUMBER_LIMIT", default=10)
+
+TENANT_INVITATION_TIMEOUT = env("TENANT_INVITATION_TIMEOUT", default=60 * 60 * 24 * 14)
diff --git a/packages/backend/conftest.py b/packages/backend/conftest.py
index 0b57acebf..97a97866e 100644
--- a/packages/backend/conftest.py
+++ b/packages/backend/conftest.py
@@ -21,6 +21,7 @@
'apps.content.tests.fixtures',
'apps.notifications.tests.fixtures',
'apps.integrations.tests.fixtures',
+ 'apps.multitenancy.tests.fixtures',
]
@@ -39,6 +40,10 @@ def mutate(self, *args, **kwargs):
def force_authenticate(self, user):
self.execute_options["context_value"].user = user
+ def set_tenant_dependent_context(self, tenant, role):
+ self.execute_options["context_value"].tenant = tenant
+ self.execute_options["context_value"].user_role = role
+
def set_cookies(self, cookies):
self.execute_options["context_value"].cookies = cookies
self.execute_options["context_value"].COOKIES = {morsel.key: morsel.coded_value for morsel in cookies.values()}
diff --git a/packages/internal/docs/.gitignore b/packages/internal/docs/.gitignore
index 488b8e8b2..bd80eac74 100644
--- a/packages/internal/docs/.gitignore
+++ b/packages/internal/docs/.gitignore
@@ -26,4 +26,5 @@ yarn-error.log*
docs/api-reference/webapp/generated
docs/api-reference/webapp-core/generated
docs/api-reference/webapp-api-client/generated
+docs/api-reference/webapp-tenants/generated
docs/api-reference/backend/generated
diff --git a/packages/internal/docs/docs/api-reference/webapp-tenants/commands.mdx b/packages/internal/docs/docs/api-reference/webapp-tenants/commands.mdx
new file mode 100644
index 000000000..510fd11f9
--- /dev/null
+++ b/packages/internal/docs/docs/api-reference/webapp-tenants/commands.mdx
@@ -0,0 +1,30 @@
+---
+title: CLI commands
+description: Commands within `webapp-tenants` package context
+---
+
+This page contains a list of CLI commands that you can use within the `webapp-tenants` package context.
+
+---
+
+### `pnpm run lint`
+
+Runs `eslint` and `stylelint` for the `webapp` package.
+
+---
+
+### `pnpm run type-check`
+
+Runs `tsc` compilation with disabled files emitting.
+
+---
+
+### `pnpm run test`
+
+Runs `jest` for the `webapp` package.
+
+---
+
+### `pnpm run test:watch`
+
+Runs `jest` with `--watch` flag. Useful for development purpose.
diff --git a/packages/internal/docs/docs/introduction/features/crud.mdx b/packages/internal/docs/docs/introduction/features/crud.mdx
index c78dc1a7b..67fcf5d05 100644
--- a/packages/internal/docs/docs/introduction/features/crud.mdx
+++ b/packages/internal/docs/docs/introduction/features/crud.mdx
@@ -2,6 +2,7 @@
title: CRUD model generator
---
import useBaseUrl from "@docusaurus/useBaseUrl";
+import ProjectName from '../../shared/components/ProjectName.component';
CRUD (Create, Read, Update, Delete) is a standard set of database operations that are commonly used in web applications.
These operations allow developers to interact with the database and perform basic functions such as creating, reading,
@@ -10,9 +11,10 @@ starting point for developers to build upon.
### CRUD Example Items
-SaaS Boilerplate comes with a built-in example CRUD module under the `CRUD Example Items` tab in the application. This
+
diff --git a/packages/internal/docs/docs/introduction/features/multi-tenancy.mdx b/packages/internal/docs/docs/introduction/features/multi-tenancy.mdx
new file mode 100644
index 000000000..5e84e8e9b
--- /dev/null
+++ b/packages/internal/docs/docs/introduction/features/multi-tenancy.mdx
@@ -0,0 +1,38 @@
+---
+title: Multitenancy
+description: Managing multiple tenants within a single account
+---
+import ProjectName from '../../shared/components/ProjectName.component';
+
+The multitenancy feature in
-The payment form allows users to make transactions with a defined amount of USD, using an already added payment method -or adding a new card for one time charge. Successful transactions are added to the transaction history list. +Tenants can initiate transactions using the payment form, specifying the amount in USD and selecting a stored payment +method or adding a new card for one-time charges. Successful transactions are recorded in the transaction history for reference.
@@ -27,7 +33,7 @@ or adding a new card for one time charge. Successful transactions are added to t
### Subscription management
-The subscription management functionality allows users to select one of the three pre-defined plans in the Stripe admin
+The subscription management functionality allows tenant owners to select one of the three pre-defined plans in the Stripe admin
panel: Free, Monthly, and Yearly. The selected plan is automatically registered as an active subscription in the system
and can be used in the business logic of the application. The system supports changing and canceling subscriptions
based on the rules defined by the application administrator in Stripe.
@@ -47,7 +53,7 @@ or if you need to adjust it to meet the specific needs of your application, plea
----
Having Stripe payments integrated from the start of a SaaS project offers several advantages. First, it allows for a
-seamless user experience, as users can easily subscribe to paid plans and make payments without leaving the application.
+seamless user experience, as tenants can easily subscribe to paid plans and make payments without leaving the application.
Second, it allows for better financial tracking and reporting, as all payment information is stored in the Stripe
merchant account. Finally, it provides a scalable payment solution that can grow with the application, as Stripe
supports a wide range of payment methods and currencies.
diff --git a/packages/internal/docs/docs/shared/partials/_sb_description.mdx b/packages/internal/docs/docs/shared/partials/_sb_description.mdx
index 82679612b..140d7a377 100644
--- a/packages/internal/docs/docs/shared/partials/_sb_description.mdx
+++ b/packages/internal/docs/docs/shared/partials/_sb_description.mdx
@@ -6,6 +6,7 @@ It consists following architecture modules:
and Continuous Deployment
- Application code that contains generic functionality like:
- [Payments and Subscriptions](/introduction/features/payments.mdx)
+ - [Multi-tenancy](/introduction/features/multi-tenancy.mdx) is easy to manage multiple tenants within a single account
- [Integration with Contentful CMS](/introduction/features/cms.mdx) as an example to be ready to build up upon
- [Authorization](/introduction/features/auth.mdx) is ready to implement custom design or adjustments
- [CRUD](/introduction/features/crud.mdx) as a shortcut to speed up the development
diff --git a/packages/internal/docs/docs/working-with-sb/graphql/backend/adding-new-mutation.mdx b/packages/internal/docs/docs/working-with-sb/graphql/backend/adding-new-mutation.mdx
index f9a32b559..58e4bf93b 100644
--- a/packages/internal/docs/docs/working-with-sb/graphql/backend/adding-new-mutation.mdx
+++ b/packages/internal/docs/docs/working-with-sb/graphql/backend/adding-new-mutation.mdx
@@ -166,6 +166,17 @@ See the API Reference for:
- [DeleteModelMutation](../../../api-reference/backend/generated/common/graphql/mutations#deletemodelmutation)
- and a more generic [SerializerMutation](../../../api-reference/backend/generated/common/graphql/mutations#serializermutation).
+:::
+
+:::info
+
+For tenant dependent models there are prepared Mutation classes with already defined `tenant_id` field in input. You can
+see usage example in CRUD module. Here is API Reference for above, all are inherited from base `ModelMutation` classes:
+- [CreateTenantDependentModelMutation](../../../api-reference/backend/generated/common/graphql/mutations#createtenantdependentmodelmutation)
+- [UpdateTenantDependentModelMutation](../../../api-reference/backend/generated/common/graphql/mutations#updatetenantdependentmodelmutation)
+- [DeleteTenantDependentModelMutation](../../../api-reference/backend/generated/common/graphql/mutations#deletetenantdependentmodelmutation)
+
+
:::
### Create mutation fields in schema
diff --git a/packages/internal/docs/docusaurus.config.js b/packages/internal/docs/docusaurus.config.js
index 47e50f5d0..93d2baab2 100644
--- a/packages/internal/docs/docusaurus.config.js
+++ b/packages/internal/docs/docusaurus.config.js
@@ -124,6 +124,21 @@ module.exports = {
watch: process.env.TYPEDOC_WATCH,
},
],
+ [
+ 'docusaurus-plugin-typedoc',
+ {
+ id: 'typedoc-webapp-tenants',
+ entryPoints: [
+ '../../webapp-libs/webapp-tenants/src/hooks/index.ts',
+ '../../webapp-libs/webapp-tenants/src/providers/index.ts',
+ '../../webapp-libs/webapp-tenants/src/tests/utils/rendering.tsx',
+ ],
+ tsconfig: '../../webapp-libs/webapp-tenants/tsconfig.lib.json',
+ out: 'api-reference/webapp-tenants/generated',
+ readme: 'none',
+ watch: process.env.TYPEDOC_WATCH,
+ },
+ ],
[
'docusaurus-plugin-typedoc',
{
diff --git a/packages/internal/docs/sidebars.js b/packages/internal/docs/sidebars.js
index 43b3b13ae..3d6bb76c4 100644
--- a/packages/internal/docs/sidebars.js
+++ b/packages/internal/docs/sidebars.js
@@ -118,6 +118,17 @@ module.exports = {
collapsed: false,
items: ['api-reference/webapp-emails/commands'],
},
+ {
+ type: 'category',
+ label: 'webapp-tenants',
+ collapsed: false,
+ items: [
+ {
+ type: 'autogenerated',
+ dirName: 'api-reference/webapp-tenants/generated',
+ },
+ ],
+ },
{
type: 'category',
label: 'tools',
@@ -208,7 +219,7 @@ module.exports = {
'aws/architecture/cicd-architecture',
],
},
- 'aws/troubleshooting'
+ 'aws/troubleshooting',
],
},
],
@@ -238,6 +249,7 @@ module.exports = {
'introduction/features/emails',
'introduction/features/notifications',
'introduction/features/openai',
+ 'introduction/features/multi-tenancy',
'introduction/features/payments',
'introduction/features/iac',
'introduction/features/cicd',
diff --git a/packages/webapp-libs/webapp-api-client/graphql/schema/api.graphql b/packages/webapp-libs/webapp-api-client/graphql/schema/api.graphql
index bf98334f5..8ede3e108 100644
--- a/packages/webapp-libs/webapp-api-client/graphql/schema/api.graphql
+++ b/packages/webapp-libs/webapp-api-client/graphql/schema/api.graphql
@@ -36,20 +36,19 @@ that will be handled by the multipart request spec
"""
scalar Upload
type Query {
+ allTenants(before: String, after: String, first: Int, last: Int): TenantConnection
+ tenant(id: ID): TenantType
allSubscriptionPlans(before: String, after: String, first: Int, last: Int): StripePriceConnection
- activeSubscription: SubscriptionScheduleType
- allPaymentMethods(before: String, after: String, first: Int, last: Int): PaymentMethodConnection
- allCharges(before: String, after: String, first: Int, last: Int): ChargeConnection
- charge(id: ID): StripeChargeType
- paymentIntent(id: ID): StripePaymentIntentType
+ activeSubscription(tenantId: ID): SubscriptionScheduleType
+ allPaymentMethods(tenantId: ID, before: String, after: String, first: Int, last: Int): PaymentMethodConnection
+ allCharges(tenantId: ID, before: String, after: String, first: Int, last: Int): ChargeConnection
+ charge(id: ID, tenantId: ID): StripeChargeType
+ paymentIntent(id: ID, tenantId: ID): StripePaymentIntentType
currentUser: CurrentUserType
hasUnreadNotifications: Boolean
allNotifications(before: String, after: String, first: Int, last: Int): NotificationConnection
- crudDemoItem(
- "The ID of the object"
- id: ID!
- ): CrudDemoItemType
- allCrudDemoItems(before: String, after: String, first: Int, last: Int): CrudDemoItemConnection
+ crudDemoItem(id: ID, tenantId: ID): CrudDemoItemType
+ allCrudDemoItems(tenantId: ID, before: String, after: String, first: Int, last: Int): CrudDemoItemConnection
allContentfulDemoItemFavorites(before: String, after: String, first: Int, last: Int): ContentfulDemoItemFavoriteConnection
allDocumentDemoItems(before: String, after: String, first: Int, last: Int): DocumentDemoItemConnection
node(
@@ -57,11 +56,11 @@ type Query {
id: ID!
): Node
}
-type StripePriceConnection {
+type TenantConnection {
"Pagination data for this connection."
pageInfo: PageInfo!
"Contains the nodes in this connection."
- edges: [StripePriceEdge]!
+ edges: [TenantEdge]!
}
"The Relay compliant `PageInfo` type, containing data necessary to paginate this connection."
type PageInfo {
@@ -74,6 +73,40 @@ type PageInfo {
"When paginating forwards, the cursor to continue."
endCursor: String
}
+"A Relay edge containing a `Tenant` and its cursor."
+type TenantEdge {
+ "The item at the end of the edge"
+ node: TenantType
+ "A cursor for use in pagination"
+ cursor: String!
+}
+type TenantType implements Node {
+ id: ID!
+ name: String
+ slug: String
+ type: String
+ billingEmail: String
+ userMemberships: [TenantMembershipType]
+ membership: TenantMembershipType!
+}
+type TenantMembershipType implements Node {
+ id: ID!
+ role: TenantUserRole
+ inviteeEmailAddress: String
+ invitationAccepted: Boolean
+ userId: ID
+ invitationToken: String
+ firstName: String
+ lastName: String
+ userEmail: String
+ avatar: String
+}
+type StripePriceConnection {
+ "Pagination data for this connection."
+ pageInfo: PageInfo!
+ "Contains the nodes in this connection."
+ edges: [StripePriceEdge]!
+}
"A Relay edge containing a `StripePrice` and its cursor."
type StripePriceEdge {
"The item at the end of the edge"
@@ -589,6 +622,7 @@ type CurrentUserType {
email: String!
otpEnabled: Boolean!
otpVerified: Boolean!
+ tenants: [TenantType]
firstName: String
lastName: String
roles: [String]
@@ -625,6 +659,7 @@ type UserType {
avatar: String
}
type CrudDemoItemType implements Node {
+ tenant: TenantType
"The ID of the object"
id: ID!
name: String!
@@ -657,12 +692,12 @@ type ContentfulDemoItemFavoriteEdge {
cursor: String!
}
type ContentfulDemoItemFavoriteType implements Node {
+ createdAt: DateTime!
+ updatedAt: DateTime!
"The ID of the object"
id: ID!
item: ContentfulDemoItemType!
user: CurrentUserType!
- createdAt: DateTime!
- updatedAt: DateTime!
}
type ContentfulDemoItemType implements Node {
"The ID of the object"
@@ -710,6 +745,14 @@ type FileFieldType {
name: String
}
type ApiMutation {
+ updateTenant(input: UpdateTenantMutationInput!): UpdateTenantMutationPayload
+ deleteTenant(input: DeleteTenantMutationInput!): DeleteTenantMutationPayload
+ createTenantInvitation(input: CreateTenantInvitationMutationInput!): CreateTenantInvitationMutationPayload
+ updateTenantMembership(input: UpdateTenantMembershipMutationInput!): UpdateTenantMembershipMutationPayload
+ createTenant(input: CreateTenantMutationInput!): CreateTenantMutationPayload
+ acceptTenantInvitation(input: AcceptTenantInvitationMutationInput!): AcceptTenantInvitationMutationPayload
+ declineTenantInvitation(input: DeclineTenantInvitationMutationInput!): DeclineTenantInvitationMutationPayload
+ deleteTenantMembership(input: DeleteTenantMembershipMutationInput!): DeleteTenantMembershipMutationPayload
generateSaasIdeas(input: GenerateSaasIdeasMutationInput!): GenerateSaasIdeasMutationPayload
changeActiveSubscription(input: ChangeActiveSubscriptionMutationInput!): ChangeActiveSubscriptionMutationPayload
cancelActiveSubscription(input: CancelActiveSubscriptionMutationInput!): CancelActiveSubscriptionMutationPayload
@@ -739,6 +782,44 @@ type ApiMutation {
createFavoriteContentfulDemoItem(input: CreateFavoriteContentfulDemoItemMutationInput!): CreateFavoriteContentfulDemoItemMutationPayload
deleteFavoriteContentfulDemoItem(input: DeleteFavoriteContentfulDemoItemMutationInput!): DeleteFavoriteContentfulDemoItemMutationPayload
}
+type UpdateTenantMutationPayload {
+ tenant: TenantType
+ tenantEdge: TenantEdge
+ clientMutationId: String
+}
+type DeleteTenantMutationPayload {
+ deletedIds: [ID]
+ clientMutationId: String
+}
+type CreateTenantInvitationMutationPayload {
+ email: String
+ role: TenantUserRole
+ tenantId: String
+ ok: Boolean
+ clientMutationId: String
+}
+type UpdateTenantMembershipMutationPayload {
+ tenantMembership: TenantMembershipType
+ tenantMembershipEdge: TenantEdge
+ clientMutationId: String
+}
+type CreateTenantMutationPayload {
+ tenant: TenantType
+ tenantEdge: TenantEdge
+ clientMutationId: String
+}
+type AcceptTenantInvitationMutationPayload {
+ ok: Boolean
+ clientMutationId: String
+}
+type DeclineTenantInvitationMutationPayload {
+ ok: Boolean
+ clientMutationId: String
+}
+type DeleteTenantMembershipMutationPayload {
+ deletedIds: [ID]
+ clientMutationId: String
+}
type GenerateSaasIdeasMutationPayload {
ideas: [String]
clientMutationId: String
@@ -937,6 +1018,11 @@ interface Node {
"The ID of the object"
id: ID!
}
+enum TenantUserRole {
+ OWNER
+ ADMIN
+ MEMBER
+}
enum DjstripeProductTypeChoices {
"Good"
GOOD
@@ -1351,35 +1437,88 @@ enum DjstripeSetupIntentUsageChoices {
"On session"
ON_SESSION
}
+input UpdateTenantMutationInput {
+ name: String!
+ billingEmail: String
+ id: ID!
+ clientMutationId: String
+}
+input DeleteTenantMutationInput {
+ id: String
+ clientMutationId: String
+}
+input CreateTenantInvitationMutationInput {
+ email: String!
+ role: TenantUserRole!
+ tenantId: String!
+ clientMutationId: String
+}
+input UpdateTenantMembershipMutationInput {
+ id: ID!
+ tenantId: String!
+ role: TenantUserRole!
+ clientMutationId: String
+}
+input CreateTenantMutationInput {
+ name: String!
+ billingEmail: String
+ clientMutationId: String
+}
+input AcceptTenantInvitationMutationInput {
+ id: String!
+ "Token"
+ token: String!
+ clientMutationId: String
+}
+input DeclineTenantInvitationMutationInput {
+ id: String!
+ "Token"
+ token: String!
+ clientMutationId: String
+}
+input DeleteTenantMembershipMutationInput {
+ id: String
+ tenantId: String!
+ clientMutationId: String
+}
input GenerateSaasIdeasMutationInput {
keywords: [String]
clientMutationId: String
}
input ChangeActiveSubscriptionMutationInput {
+ id: String
+ tenantId: String!
price: String!
clientMutationId: String
}
input CancelActiveSubscriptionMutationInput {
+ id: String
+ tenantId: String!
clientMutationId: String
}
input UpdateDefaultPaymentMethodMutationInput {
id: String
+ tenantId: String!
clientMutationId: String
}
input DeletePaymentMethodMutationInput {
id: String
+ tenantId: String!
clientMutationId: String
}
input CreatePaymentIntentMutationInput {
+ tenantId: String!
product: String!
clientMutationId: String
}
input UpdatePaymentIntentMutationInput {
- product: String!
id: ID!
+ tenantId: String!
+ product: String!
clientMutationId: String
}
input CreateSetupIntentMutationInput {
+ tenantId: String!
clientMutationId: String
}
input ChangePasswordMutationInput {
@@ -1448,18 +1587,21 @@ input MarkReadAllNotificationsMutationInput {
clientMutationId: String
}
input CreateCrudDemoItemMutationInput {
+ tenantId: String!
name: String!
createdBy: String
clientMutationId: String
}
input UpdateCrudDemoItemMutationInput {
+ id: ID!
+ tenantId: String!
name: String!
createdBy: String
- id: ID!
clientMutationId: String
}
input DeleteCrudDemoItemMutationInput {
id: String
+ tenantId: String!
clientMutationId: String
}
input CreateDocumentDemoItemMutationInput {
diff --git a/packages/webapp-libs/webapp-api-client/src/constants/index.ts b/packages/webapp-libs/webapp-api-client/src/constants/index.ts
new file mode 100644
index 000000000..de2a8735f
--- /dev/null
+++ b/packages/webapp-libs/webapp-api-client/src/constants/index.ts
@@ -0,0 +1 @@
+export * from './tenant.types';
diff --git a/packages/webapp-libs/webapp-api-client/src/constants/tenant.types.ts b/packages/webapp-libs/webapp-api-client/src/constants/tenant.types.ts
new file mode 100644
index 000000000..af3c2e6b5
--- /dev/null
+++ b/packages/webapp-libs/webapp-api-client/src/constants/tenant.types.ts
@@ -0,0 +1,4 @@
+export enum TenantType {
+ PERSONAL = 'default',
+ ORGANIZATION = 'organization',
+}
diff --git a/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/gql.ts b/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/gql.ts
index 63b2f49c6..79b7b4e5b 100644
--- a/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/gql.ts
+++ b/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/gql.ts
@@ -15,7 +15,8 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/
const documents = {
"\n query paginationListTestQuery($first: Int, $after: String, $last: Int, $before: String) {\n allNotifications(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n": types.PaginationListTestQueryDocument,
"\n fragment commonQueryCurrentUserFragment on CurrentUserType {\n id\n email\n firstName\n lastName\n roles\n avatar\n otpVerified\n otpEnabled\n }\n": types.CommonQueryCurrentUserFragmentFragmentDoc,
- "\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n }\n }\n": types.CommonQueryCurrentUserQueryDocument,
+ "\n fragment commonQueryTenantItemFragment on TenantType {\n id\n name\n type\n membership {\n id\n role\n invitationAccepted\n invitationToken\n }\n }\n": types.CommonQueryTenantItemFragmentFragmentDoc,
+ "\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n tenants {\n ...commonQueryTenantItemFragment\n }\n }\n }\n": types.CommonQueryCurrentUserQueryDocument,
"\n query configContentfulAppConfigQuery {\n appConfigCollection(limit: 1) {\n items {\n name\n privacyPolicy\n termsAndConditions\n }\n }\n }\n": types.ConfigContentfulAppConfigQueryDocument,
"\n mutation useFavoriteDemoItemListCreateMutation($input: CreateFavoriteContentfulDemoItemMutationInput!) {\n createFavoriteContentfulDemoItem(input: $input) {\n contentfulDemoItemFavoriteEdge {\n node {\n id\n item {\n pk\n }\n }\n }\n }\n }\n": types.UseFavoriteDemoItemListCreateMutationDocument,
"\n fragment useFavoriteDemoItem_item on ContentfulDemoItemFavoriteType {\n id\n item {\n pk\n }\n }\n": types.UseFavoriteDemoItem_ItemFragmentDoc,
@@ -25,13 +26,13 @@ const documents = {
"\n fragment demoItemListItemFragment on DemoItem {\n title\n image {\n title\n url\n }\n }\n": types.DemoItemListItemFragmentFragmentDoc,
"\n query demoItemsAllQuery {\n demoItemCollection {\n items {\n sys {\n id\n }\n ...demoItemListItemFragment\n }\n }\n }\n": types.DemoItemsAllQueryDocument,
"\n mutation addCrudDemoItemMutation($input: CreateCrudDemoItemMutationInput!) {\n createCrudDemoItem(input: $input) {\n crudDemoItemEdge {\n node {\n id\n name\n }\n }\n }\n }\n": types.AddCrudDemoItemMutationDocument,
- "\n query crudDemoItemDetailsQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n": types.CrudDemoItemDetailsQueryDocument,
- "\n query crudDemoItemListQuery($first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n": types.CrudDemoItemListQueryDocument,
+ "\n query crudDemoItemDetailsQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n": types.CrudDemoItemDetailsQueryDocument,
+ "\n query crudDemoItemListQuery($tenantId: ID!, $first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(tenantId: $tenantId, first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n": types.CrudDemoItemListQueryDocument,
"\n query crudDemoItemListItemTestQuery {\n item: crudDemoItem(id: \"test-id\") {\n ...crudDemoItemListItem\n }\n }\n": types.CrudDemoItemListItemTestQueryDocument,
"\n mutation crudDemoItemListItemDeleteMutation($input: DeleteCrudDemoItemMutationInput!) {\n deleteCrudDemoItem(input: $input) {\n deletedIds\n }\n }\n": types.CrudDemoItemListItemDeleteMutationDocument,
"\n fragment crudDemoItemListItem on CrudDemoItemType {\n id\n name\n }\n": types.CrudDemoItemListItemFragmentDoc,
"\n query crudDemoItemListItemDefaultStoryQuery {\n item: crudDemoItem(id: \"test-id\") {\n ...crudDemoItemListItem\n }\n }\n": types.CrudDemoItemListItemDefaultStoryQueryDocument,
- "\n query editCrudDemoItemQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n": types.EditCrudDemoItemQueryDocument,
+ "\n query editCrudDemoItemQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n": types.EditCrudDemoItemQueryDocument,
"\n mutation editCrudDemoItemContentMutation($input: UpdateCrudDemoItemMutationInput!) {\n updateCrudDemoItem(input: $input) {\n crudDemoItem {\n id\n name\n }\n }\n }\n": types.EditCrudDemoItemContentMutationDocument,
"\n fragment documentListItem on DocumentDemoItemType {\n id\n file {\n url\n name\n }\n createdAt\n }\n": types.DocumentListItemFragmentDoc,
"\n query documentsListQuery {\n allDocumentDemoItems(first: 10) {\n edges {\n node {\n id\n ...documentListItem\n }\n }\n }\n }\n": types.DocumentsListQueryDocument,
@@ -41,18 +42,18 @@ const documents = {
"\n mutation stripeCreatePaymentIntentMutation_($input: CreatePaymentIntentMutationInput!) {\n createPaymentIntent(input: $input) {\n paymentIntent {\n ...stripePaymentIntentFragment\n id\n amount\n clientSecret\n currency\n pk\n }\n }\n }\n": types.StripeCreatePaymentIntentMutation_Document,
"\n mutation stripeUpdatePaymentIntentMutation_($input: UpdatePaymentIntentMutationInput!) {\n updatePaymentIntent(input: $input) {\n paymentIntent {\n ...stripePaymentIntentFragment\n id\n amount\n clientSecret\n currency\n pk\n }\n }\n }\n": types.StripeUpdatePaymentIntentMutation_Document,
"\n fragment stripePaymentMethodFragment on StripePaymentMethodType {\n id\n pk\n type\n card\n billingDetails\n }\n": types.StripePaymentMethodFragmentFragmentDoc,
- "\n query stripeSubscriptionQuery {\n allPaymentMethods(first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n": types.StripeSubscriptionQueryDocument,
+ "\n query stripeSubscriptionQuery($tenantId: ID!) {\n allPaymentMethods(tenantId: $tenantId, first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n": types.StripeSubscriptionQueryDocument,
"\n mutation stripeDeletePaymentMethodMutation($input: DeletePaymentMethodMutationInput!) {\n deletePaymentMethod(input: $input) {\n deletedIds\n activeSubscription {\n defaultPaymentMethod {\n ...stripePaymentMethodFragment\n }\n }\n }\n }\n": types.StripeDeletePaymentMethodMutationDocument,
"\n mutation stripeUpdateDefaultPaymentMethodMutation($input: UpdateDefaultPaymentMethodMutationInput!) {\n updateDefaultPaymentMethod(input: $input) {\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n paymentMethodEdge {\n node {\n ...stripePaymentMethodFragment\n id\n }\n }\n }\n }\n": types.StripeUpdateDefaultPaymentMethodMutationDocument,
"\n fragment subscriptionActiveSubscriptionFragment on SubscriptionScheduleType {\n phases {\n startDate\n endDate\n trialEnd\n item {\n price {\n pk\n product {\n id\n name\n }\n unitAmount\n id\n }\n quantity\n }\n }\n subscription {\n startDate\n trialEnd\n trialStart\n id\n }\n canActivateTrial\n defaultPaymentMethod {\n ...stripePaymentMethodFragment_\n id\n }\n }\n\n fragment subscriptionPlanItemFragment on SubscriptionPlanType {\n id\n pk\n product {\n id\n name\n }\n amount\n }\n": types.SubscriptionActiveSubscriptionFragmentFragmentDoc,
"\n fragment subscriptionActiveSubscriptionDetailsFragment on SubscriptionScheduleType {\n phases {\n startDate\n endDate\n trialEnd\n item {\n price {\n ...subscriptionPriceItemFragment\n id\n }\n quantity\n }\n }\n subscription {\n startDate\n trialEnd\n trialStart\n id\n }\n canActivateTrial\n defaultPaymentMethod {\n ...stripePaymentMethodFragment_\n id\n }\n }\n\n fragment stripePaymentMethodFragment_ on StripePaymentMethodType {\n id\n pk\n type\n card\n billingDetails\n }\n": types.SubscriptionActiveSubscriptionDetailsFragmentFragmentDoc,
- "\n query subscriptionActivePlanDetailsQuery_ {\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n": types.SubscriptionActivePlanDetailsQuery_Document,
+ "\n query subscriptionActivePlanDetailsQuery_($tenantId: ID!) {\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n": types.SubscriptionActivePlanDetailsQuery_Document,
"\n mutation subscriptionCancelActiveSubscriptionMutation($input: CancelActiveSubscriptionMutationInput!) {\n cancelActiveSubscription(input: $input) {\n subscriptionSchedule {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n }\n": types.SubscriptionCancelActiveSubscriptionMutationDocument,
"\n mutation stripeCreateSetupIntentMutation_($input: CreateSetupIntentMutationInput!) {\n createSetupIntent(input: $input) {\n setupIntent {\n id\n ...stripeSetupIntentFragment\n }\n }\n }\n\n fragment stripeSetupIntentFragment on StripeSetupIntentType {\n id\n clientSecret\n }\n": types.StripeCreateSetupIntentMutation_Document,
"\n mutation subscriptionChangeActiveSubscriptionMutation($input: ChangeActiveSubscriptionMutationInput!) {\n changeActiveSubscription(input: $input) {\n subscriptionSchedule {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n }\n": types.SubscriptionChangeActiveSubscriptionMutationDocument,
"\n query subscriptionPlansAllQuery {\n allSubscriptionPlans(first: 100) {\n edges {\n node {\n ...subscriptionPriceItemFragment\n id\n pk\n product {\n id\n name\n }\n unitAmount\n }\n }\n }\n }\n": types.SubscriptionPlansAllQueryDocument,
"\n fragment subscriptionPriceItemFragment on StripePriceType {\n id\n pk\n product {\n id\n name\n }\n unitAmount\n }\n": types.SubscriptionPriceItemFragmentFragmentDoc,
- "\n query stripeAllChargesQuery {\n allCharges {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n": types.StripeAllChargesQueryDocument,
+ "\n query stripeAllChargesQuery($tenantId: ID!) {\n allCharges(tenantId: $tenantId) {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n": types.StripeAllChargesQueryDocument,
"\n fragment stripeChargeFragment on StripeChargeType {\n id\n created\n billingDetails\n paymentMethod {\n ...stripePaymentMethodFragment\n id\n }\n amount\n invoice {\n id\n subscription {\n plan {\n ...subscriptionPlanItemFragment\n }\n }\n }\n }\n": types.StripeChargeFragmentFragmentDoc,
"\n fragment subscriptionPlanItemFragment on SubscriptionPlanType {\n id\n pk\n product {\n id\n name\n }\n amount\n }\n": types.SubscriptionPlanItemFragmentFragmentDoc,
"\n mutation generateSaasIdeasMutation($input: GenerateSaasIdeasMutationInput!) {\n generateSaasIdeas(input: $input) {\n ideas\n }\n }\n": types.GenerateSaasIdeasMutationDocument,
@@ -63,6 +64,17 @@ const documents = {
"\n fragment notificationsListContentFragment on Query {\n hasUnreadNotifications\n allNotifications(first: $count, after: $cursor) {\n edges {\n node {\n id\n ...notificationsListItemFragment\n }\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n": types.NotificationsListContentFragmentFragmentDoc,
"\n fragment notificationsListItemFragment on NotificationType {\n id\n data\n createdAt\n readAt\n type\n issuer {\n id\n avatar\n email\n }\n }\n": types.NotificationsListItemFragmentFragmentDoc,
"\n mutation notificationsListMarkAsReadMutation($input: MarkReadAllNotificationsMutationInput!) {\n markReadAllNotifications(input: $input) {\n ok\n }\n }\n": types.NotificationsListMarkAsReadMutationDocument,
+ "\n mutation deleteTenantMutation($input: DeleteTenantMutationInput!) {\n deleteTenant(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n": types.DeleteTenantMutationDocument,
+ "\n mutation updateTenantMembershipMutation($input: UpdateTenantMembershipMutationInput!) {\n updateTenantMembership(input: $input) {\n tenantMembership {\n id\n }\n }\n }\n": types.UpdateTenantMembershipMutationDocument,
+ "\n mutation deleteTenantMembershipMutation($input: DeleteTenantMembershipMutationInput!) {\n deleteTenantMembership(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n": types.DeleteTenantMembershipMutationDocument,
+ "\n query tenantMembersListQuery($id: ID!) {\n tenant(id: $id) {\n userMemberships {\n id\n role\n invitationAccepted\n inviteeEmailAddress\n userId\n firstName\n lastName\n userEmail\n avatar\n }\n }\n }\n": types.TenantMembersListQueryDocument,
+ "\n fragment tenantFragment on TenantType {\n id\n name\n slug\n membership {\n role\n invitationAccepted\n }\n }\n": types.TenantFragmentFragmentDoc,
+ "\n query currentTenantQuery($id: ID!) {\n tenant(id: $id) {\n ...tenantFragment\n }\n }\n": types.CurrentTenantQueryDocument,
+ "\n mutation addTenantMutation($input: CreateTenantMutationInput!) {\n createTenant(input: $input) {\n tenantEdge {\n node {\n id\n name\n }\n }\n }\n }\n": types.AddTenantMutationDocument,
+ "\n mutation acceptTenantInvitationMutation($input: AcceptTenantInvitationMutationInput!) {\n acceptTenantInvitation(input: $input) {\n ok\n }\n }\n": types.AcceptTenantInvitationMutationDocument,
+ "\n mutation declineTenantInvitationMutation($input: DeclineTenantInvitationMutationInput!) {\n declineTenantInvitation(input: $input) {\n ok\n }\n }\n": types.DeclineTenantInvitationMutationDocument,
+ "\n mutation updateTenantMutation($input: UpdateTenantMutationInput!) {\n updateTenant(input: $input) {\n tenant {\n id\n name\n }\n }\n }\n": types.UpdateTenantMutationDocument,
+ "\n mutation createTenantInvitationMutation($input: CreateTenantInvitationMutationInput!) {\n createTenantInvitation(input: $input) {\n email\n role\n }\n }\n": types.CreateTenantInvitationMutationDocument,
"\n mutation authConfirmUserEmailMutation($input: ConfirmEmailMutationInput!) {\n confirm(input: $input) {\n ok\n }\n }\n": types.AuthConfirmUserEmailMutationDocument,
"\n mutation authChangePasswordMutation($input: ChangePasswordMutationInput!) {\n changePassword(input: $input) {\n access\n refresh\n }\n }\n": types.AuthChangePasswordMutationDocument,
"\n mutation authUpdateUserProfileMutation($input: UpdateCurrentUserMutationInput!) {\n updateCurrentUser(input: $input) {\n userProfile {\n id\n user {\n ...commonQueryCurrentUserFragment\n }\n }\n }\n }\n": types.AuthUpdateUserProfileMutationDocument,
@@ -101,7 +113,11 @@ export function gql(source: "\n fragment commonQueryCurrentUserFragment on Curr
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function gql(source: "\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n }\n }\n"): (typeof documents)["\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n }\n }\n"];
+export function gql(source: "\n fragment commonQueryTenantItemFragment on TenantType {\n id\n name\n type\n membership {\n id\n role\n invitationAccepted\n invitationToken\n }\n }\n"): (typeof documents)["\n fragment commonQueryTenantItemFragment on TenantType {\n id\n name\n type\n membership {\n id\n role\n invitationAccepted\n invitationToken\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n tenants {\n ...commonQueryTenantItemFragment\n }\n }\n }\n"): (typeof documents)["\n query commonQueryCurrentUserQuery {\n currentUser {\n ...commonQueryCurrentUserFragment\n tenants {\n ...commonQueryTenantItemFragment\n }\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -141,11 +157,11 @@ export function gql(source: "\n mutation addCrudDemoItemMutation($input: Create
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function gql(source: "\n query crudDemoItemDetailsQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n"): (typeof documents)["\n query crudDemoItemDetailsQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n"];
+export function gql(source: "\n query crudDemoItemDetailsQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n"): (typeof documents)["\n query crudDemoItemDetailsQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function gql(source: "\n query crudDemoItemListQuery($first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n"): (typeof documents)["\n query crudDemoItemListQuery($first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n"];
+export function gql(source: "\n query crudDemoItemListQuery($tenantId: ID!, $first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(tenantId: $tenantId, first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n"): (typeof documents)["\n query crudDemoItemListQuery($tenantId: ID!, $first: Int, $after: String, $last: Int, $before: String) {\n allCrudDemoItems(tenantId: $tenantId, first: $first, after: $after, last: $last, before: $before) {\n edges {\n node {\n id\n ...crudDemoItemListItem\n }\n }\n pageInfo {\n startCursor\n endCursor\n hasPreviousPage\n hasNextPage\n }\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -165,7 +181,7 @@ export function gql(source: "\n query crudDemoItemListItemDefaultStoryQuery {\n
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function gql(source: "\n query editCrudDemoItemQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n"): (typeof documents)["\n query editCrudDemoItemQuery($id: ID!) {\n crudDemoItem(id: $id) {\n id\n name\n }\n }\n"];
+export function gql(source: "\n query editCrudDemoItemQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n"): (typeof documents)["\n query editCrudDemoItemQuery($id: ID!, $tenantId: ID!) {\n crudDemoItem(id: $id, tenantId: $tenantId) {\n id\n name\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -205,7 +221,7 @@ export function gql(source: "\n fragment stripePaymentMethodFragment on StripeP
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function gql(source: "\n query stripeSubscriptionQuery {\n allPaymentMethods(first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n"): (typeof documents)["\n query stripeSubscriptionQuery {\n allPaymentMethods(first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n"];
+export function gql(source: "\n query stripeSubscriptionQuery($tenantId: ID!) {\n allPaymentMethods(tenantId: $tenantId, first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n"): (typeof documents)["\n query stripeSubscriptionQuery($tenantId: ID!) {\n allPaymentMethods(tenantId: $tenantId, first: 100) {\n edges {\n node {\n id\n pk\n type\n card\n billingDetails\n ...stripePaymentMethodFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n __typename\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -225,7 +241,7 @@ export function gql(source: "\n fragment subscriptionActiveSubscriptionDetailsF
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function gql(source: "\n query subscriptionActivePlanDetailsQuery_ {\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n"): (typeof documents)["\n query subscriptionActivePlanDetailsQuery_ {\n activeSubscription {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n"];
+export function gql(source: "\n query subscriptionActivePlanDetailsQuery_($tenantId: ID!) {\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n"): (typeof documents)["\n query subscriptionActivePlanDetailsQuery_($tenantId: ID!) {\n activeSubscription(tenantId: $tenantId) {\n ...subscriptionActiveSubscriptionFragment\n id\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -249,7 +265,7 @@ export function gql(source: "\n fragment subscriptionPriceItemFragment on Strip
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function gql(source: "\n query stripeAllChargesQuery {\n allCharges {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n"): (typeof documents)["\n query stripeAllChargesQuery {\n allCharges {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n"];
+export function gql(source: "\n query stripeAllChargesQuery($tenantId: ID!) {\n allCharges(tenantId: $tenantId) {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n"): (typeof documents)["\n query stripeAllChargesQuery($tenantId: ID!) {\n allCharges(tenantId: $tenantId) {\n edges {\n node {\n id\n ...stripeChargeFragment\n }\n }\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -290,6 +306,50 @@ export function gql(source: "\n fragment notificationsListItemFragment on Notif
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(source: "\n mutation notificationsListMarkAsReadMutation($input: MarkReadAllNotificationsMutationInput!) {\n markReadAllNotifications(input: $input) {\n ok\n }\n }\n"): (typeof documents)["\n mutation notificationsListMarkAsReadMutation($input: MarkReadAllNotificationsMutationInput!) {\n markReadAllNotifications(input: $input) {\n ok\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n mutation deleteTenantMutation($input: DeleteTenantMutationInput!) {\n deleteTenant(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n"): (typeof documents)["\n mutation deleteTenantMutation($input: DeleteTenantMutationInput!) {\n deleteTenant(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n mutation updateTenantMembershipMutation($input: UpdateTenantMembershipMutationInput!) {\n updateTenantMembership(input: $input) {\n tenantMembership {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation updateTenantMembershipMutation($input: UpdateTenantMembershipMutationInput!) {\n updateTenantMembership(input: $input) {\n tenantMembership {\n id\n }\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n mutation deleteTenantMembershipMutation($input: DeleteTenantMembershipMutationInput!) {\n deleteTenantMembership(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n"): (typeof documents)["\n mutation deleteTenantMembershipMutation($input: DeleteTenantMembershipMutationInput!) {\n deleteTenantMembership(input: $input) {\n deletedIds\n clientMutationId\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n query tenantMembersListQuery($id: ID!) {\n tenant(id: $id) {\n userMemberships {\n id\n role\n invitationAccepted\n inviteeEmailAddress\n userId\n firstName\n lastName\n userEmail\n avatar\n }\n }\n }\n"): (typeof documents)["\n query tenantMembersListQuery($id: ID!) {\n tenant(id: $id) {\n userMemberships {\n id\n role\n invitationAccepted\n inviteeEmailAddress\n userId\n firstName\n lastName\n userEmail\n avatar\n }\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n fragment tenantFragment on TenantType {\n id\n name\n slug\n membership {\n role\n invitationAccepted\n }\n }\n"): (typeof documents)["\n fragment tenantFragment on TenantType {\n id\n name\n slug\n membership {\n role\n invitationAccepted\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n query currentTenantQuery($id: ID!) {\n tenant(id: $id) {\n ...tenantFragment\n }\n }\n"): (typeof documents)["\n query currentTenantQuery($id: ID!) {\n tenant(id: $id) {\n ...tenantFragment\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n mutation addTenantMutation($input: CreateTenantMutationInput!) {\n createTenant(input: $input) {\n tenantEdge {\n node {\n id\n name\n }\n }\n }\n }\n"): (typeof documents)["\n mutation addTenantMutation($input: CreateTenantMutationInput!) {\n createTenant(input: $input) {\n tenantEdge {\n node {\n id\n name\n }\n }\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n mutation acceptTenantInvitationMutation($input: AcceptTenantInvitationMutationInput!) {\n acceptTenantInvitation(input: $input) {\n ok\n }\n }\n"): (typeof documents)["\n mutation acceptTenantInvitationMutation($input: AcceptTenantInvitationMutationInput!) {\n acceptTenantInvitation(input: $input) {\n ok\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n mutation declineTenantInvitationMutation($input: DeclineTenantInvitationMutationInput!) {\n declineTenantInvitation(input: $input) {\n ok\n }\n }\n"): (typeof documents)["\n mutation declineTenantInvitationMutation($input: DeclineTenantInvitationMutationInput!) {\n declineTenantInvitation(input: $input) {\n ok\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n mutation updateTenantMutation($input: UpdateTenantMutationInput!) {\n updateTenant(input: $input) {\n tenant {\n id\n name\n }\n }\n }\n"): (typeof documents)["\n mutation updateTenantMutation($input: UpdateTenantMutationInput!) {\n updateTenant(input: $input) {\n tenant {\n id\n name\n }\n }\n }\n"];
+/**
+ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function gql(source: "\n mutation createTenantInvitationMutation($input: CreateTenantInvitationMutationInput!) {\n createTenantInvitation(input: $input) {\n email\n role\n }\n }\n"): (typeof documents)["\n mutation createTenantInvitationMutation($input: CreateTenantInvitationMutationInput!) {\n createTenantInvitation(input: $input) {\n email\n role\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
diff --git a/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/graphql.ts b/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/graphql.ts
index 323b2565f..aa732d1a7 100644
--- a/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/graphql.ts
+++ b/packages/webapp-libs/webapp-api-client/src/graphql/__generated/gql/graphql.ts
@@ -55,8 +55,22 @@ export type Scalars = {
Upload: { input: any; output: any; }
};
+export type AcceptTenantInvitationMutationInput = {
+ clientMutationId?: InputMaybe & WrapperProps;
/**
@@ -114,7 +114,7 @@ export type CustomRenderOptions<
function customRender<
Q extends Queries = typeof queries,
Container extends Element | DocumentFragment = HTMLElement,
- BaseElement extends Element | DocumentFragment = Container
+ BaseElement extends Element | DocumentFragment = Container,
>(
ui: ReactElement,
options: CustomRenderOptions
= {}
diff --git a/packages/webapp-libs/webapp-contentful/graphql/schema/contentful.graphql b/packages/webapp-libs/webapp-contentful/graphql/schema/contentful.graphql
index 14f2823bd..0e3a93eb4 100644
--- a/packages/webapp-libs/webapp-contentful/graphql/schema/contentful.graphql
+++ b/packages/webapp-libs/webapp-contentful/graphql/schema/contentful.graphql
@@ -19,6 +19,7 @@ type Query {
appConfig(id: String!, preview: Boolean, locale: String): AppConfig
appConfigCollection(skip: Int = 0, limit: Int = 100, preview: Boolean, locale: String, where: AppConfigFilter, order: [AppConfigOrder]): AppConfigCollection
entryCollection(skip: Int = 0, limit: Int = 100, preview: Boolean, locale: String, where: EntryFilter, order: [EntryOrder]): EntryCollection
+ _node(id: ID!, preview: Boolean, locale: String): _Node
}
"Represents a binary file in a space. An asset can be any file type."
type Asset {
@@ -55,7 +56,7 @@ type ContentfulTag {
}
type AssetLinkingCollections {
entryCollection(skip: Int = 0, limit: Int = 100, preview: Boolean, locale: String): EntryCollection
- demoItemCollection(skip: Int = 0, limit: Int = 100, preview: Boolean, locale: String, order: [AssetLinkingCollectionsDemoItemCollectionOrder]): DemoItemCollection
+ demoItemCollection(skip: Int = 0, limit: Int = 100, preview: Boolean, locale: String): DemoItemCollection
}
type EntryCollection {
total: Int!
@@ -109,6 +110,9 @@ interface Entry {
sys: Sys!
contentfulMetadata: ContentfulMetadata!
}
+interface _Node {
+ _id: ID!
+}
enum ImageResizeStrategy {
"Resizes the image to fit into the specified dimensions."
FIT
@@ -171,18 +175,6 @@ enum ImageFormat {
WEBP
AVIF
}
-enum AssetLinkingCollectionsDemoItemCollectionOrder {
- title_ASC
- title_DESC
- sys_id_ASC
- sys_id_DESC
- sys_publishedAt_ASC
- sys_publishedAt_DESC
- sys_firstPublishedAt_ASC
- sys_firstPublishedAt_DESC
- sys_publishedVersion_ASC
- sys_publishedVersion_DESC
-}
enum AssetOrder {
url_ASC
url_DESC
diff --git a/packages/webapp-libs/webapp-core/package.json b/packages/webapp-libs/webapp-core/package.json
index e34c88307..a9a2996a5 100644
--- a/packages/webapp-libs/webapp-core/package.json
+++ b/packages/webapp-libs/webapp-core/package.json
@@ -9,16 +9,21 @@
"type-check": "tsc --noEmit --project tsconfig.lib.json"
},
"dependencies": {
+ "@radix-ui/react-alert-dialog": "1.0.5",
"@radix-ui/react-avatar": "1.0.4",
"@radix-ui/react-checkbox": "1.0.4",
"@radix-ui/react-dialog": "1.0.5",
+ "@radix-ui/react-dropdown-menu": "2.0.6",
+ "@radix-ui/react-icons": "1.3.0",
"@radix-ui/react-label": "2.0.2",
"@radix-ui/react-popover": "1.0.7",
"@radix-ui/react-radio-group": "1.1.3",
+ "@radix-ui/react-select": "1.2.2",
"@radix-ui/react-separator": "1.0.3",
"@radix-ui/react-slot": "1.0.2",
"@radix-ui/react-tabs": "1.0.4",
"@radix-ui/react-toast": "1.1.5",
+ "@radix-ui/react-tooltip": "1.0.7",
"color": "^4.2.3",
"core-js": "^3.37.1",
"lodash.throttle": "^4.1.1",
diff --git a/packages/webapp-libs/webapp-core/src/components/alertDialog/__tests__/alertDialog.component.spec.tsx b/packages/webapp-libs/webapp-core/src/components/alertDialog/__tests__/alertDialog.component.spec.tsx
new file mode 100644
index 000000000..f67ef2551
--- /dev/null
+++ b/packages/webapp-libs/webapp-core/src/components/alertDialog/__tests__/alertDialog.component.spec.tsx
@@ -0,0 +1,59 @@
+import { fireEvent, screen } from '@testing-library/react';
+import React from 'react';
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '../';
+import { render } from '../../../tests/utils/rendering';
+
+const triggerText = 'Open';
+const titleText = 'Title';
+const descriptionText = 'Description';
+const cancelCTA = 'Cancel';
+const actionCTA = 'Continue';
+
+const Component = () => (
+