Skip to content

Commit

Permalink
feat!: apptension#382 Multi-tenancy / Add support for multiple tenants (
Browse files Browse the repository at this point in the history
apptension#561)

BREAKING CHANGE: Important migration instructions

Before running the multi-tenancy migrations on your existing codebase or database, it is crucial to follow these steps to avoid any issues:
1. Remove or comment `DJSTRIPE_SUBSCRIBER_MODEL` setting
2. Run the migrations
3. Revert the change: After the migrations have successfully completed, revert the change by uncommenting or re-adding the `DJSTRIPE_SUBSCRIBER_MODEL` setting.
  • Loading branch information
mkleszcz committed May 22, 2024
1 parent 569fcee commit e6c6dc3
Show file tree
Hide file tree
Showing 338 changed files with 11,716 additions and 737 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"@sb/webapp-documents/**",
"@sb/webapp-finances/**",
"@sb/webapp-generative-ai/**",
"@sb/webapp-notifications/**"
"@sb/webapp-notifications/**",
"@sb/webapp-tenants/**"
],
"depConstraints": [
{
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/webapp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ jobs:
- webapp-emails
- webapp-finances
- webapp-generative-ai
- webapp-tenants
steps:
- uses: actions/checkout@v3
with:
Expand Down Expand Up @@ -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 }}"
4 changes: 4 additions & 0 deletions .versionrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
27 changes: 27 additions & 0 deletions bitbucket-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -501,6 +526,7 @@ pipelines:
- step: *webappEmailsTest
- step: *webappFinancesTest
- step: *webappGenAiTest
- step: *webappTenantsTest

- step: *backendBuildAndTest

Expand Down Expand Up @@ -531,6 +557,7 @@ pipelines:
- step: *webappEmailsTest
- step: *webappFinancesTest
- step: *webappGenAiTest
- step: *webappTenantsTest

- step: *backendBuildAndTest

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions packages/backend/apps/demo/migrations/0003_cruddemoitem_tenant.py
Original file line number Diff line number Diff line change
@@ -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),
]
8 changes: 3 additions & 5 deletions packages/backend/apps/demo/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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
11 changes: 5 additions & 6 deletions packages/backend/apps/demo/notifications.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from graphql_relay import to_global_id

from apps.notifications import sender
Expand All @@ -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)),
Expand All @@ -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(
Expand Down
33 changes: 24 additions & 9 deletions packages/backend/apps/demo/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/apps/demo/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/apps/demo/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit e6c6dc3

Please sign in to comment.