Skip to content

Commit

Permalink
feat: multi tenancy basic configuration (#480)
Browse files Browse the repository at this point in the history
* Add multitenancy app with some basic model configuration

* Add creation of signup tenant and add it in currentUser query schema.

* Add tests

* Add short documentation for multi tenancy manager

* PR fixes & linter fix

* Add migration

* Documentation grammar
  • Loading branch information
wojcikmat authored Feb 22, 2024
1 parent dc6a710 commit 65e37f9
Show file tree
Hide file tree
Showing 18 changed files with 290 additions and 13 deletions.
6 changes: 2 additions & 4 deletions packages/backend/apps/demo/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from apps.content import models as content_models
from common.storages import UniqueFilePathGenerator
from common.models import TimestampedMixin

User = get_user_model()

Expand All @@ -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']]

Expand Down
Empty file.
3 changes: 3 additions & 0 deletions packages/backend/apps/multitenancy/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# from django.contrib import admin

# Register your models here.
6 changes: 6 additions & 0 deletions packages/backend/apps/multitenancy/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class MultitenancyConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.multitenancy'
18 changes: 18 additions & 0 deletions packages/backend/apps/multitenancy/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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):
OWNER = 'owner', 'Owner'
ADMIN = 'admin', 'Administrator'
MEMBER = 'member', 'Member'
26 changes: 26 additions & 0 deletions packages/backend/apps/multitenancy/managers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.db import models

from .constants import TenantType


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)

return new_tenant, True
84 changes: 84 additions & 0 deletions packages/backend/apps/multitenancy/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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
),
),
]
Empty file.
42 changes: 42 additions & 0 deletions packages/backend/apps/multitenancy/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import hashid_field

from django.db import models
from django.conf import settings
from django.utils.text import slugify

from . import constants
from .managers import TenantManager
from common.models import TimestampedMixin


class Tenant(TimestampedMixin, models.Model):
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=True)
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
)

objects = TenantManager()

def __str__(self):
return self.name

def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)


class TenantMembership(TimestampedMixin, models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="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")

class Meta:
unique_together = ('user', 'tenant')

def __str__(self):
return f"{self.user.email} {self.tenant.name} {self.role}"
Empty file.
26 changes: 26 additions & 0 deletions packages/backend/apps/multitenancy/tests/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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')
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")
tenant = factory.SubFactory(TenantFactory)
created_at = factory.Faker('date_time')
updated_at = factory.Faker('date_time')

class Meta:
model = models.TenantMembership
5 changes: 5 additions & 0 deletions packages/backend/apps/multitenancy/tests/fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import pytest_factoryboy

from . import factories

pytest_factoryboy.register(factories.TenantFactory)
29 changes: 28 additions & 1 deletion packages/backend/apps/users/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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 . import models
from . import serializers
from .services.users import get_user_from_resolver, get_role_names, get_user_avatar_url
Expand Down Expand Up @@ -113,6 +114,23 @@ class Meta:
serializer_class = serializers.DisableOTPSerializer


class TenantType(DjangoObjectType):
id = graphene.String()
name = graphene.String()
slug = graphene.String()
type = graphene.String()
role = graphene.String()

class Meta:
model = Tenant
fields = ("id", "name", "slug", "type", "role")

@staticmethod
def resolve_role(parent, info):
user = get_user_from_resolver(info)
return parent.user_memberships.filter(user=user).first().role


@permission_classes(policies.AnyoneFullAccess)
class AnyoneMutation(graphene.ObjectType):
token_auth = ObtainTokenMutation.Field()
Expand All @@ -134,11 +152,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):
Expand All @@ -156,6 +175,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:
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/apps/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)}


Expand Down
8 changes: 8 additions & 0 deletions packages/backend/apps/users/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from djstripe.models import Customer

from common.acl.helpers import CommonGroups
from apps.multitenancy.models import Tenant


class GroupFactory(factory.django.DjangoModelFactory):
Expand Down Expand Up @@ -47,6 +48,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:
Expand Down
Loading

0 comments on commit 65e37f9

Please sign in to comment.