Introduce workspace tenancy boundaries

This commit is contained in:
rucki
2026-09-09 17:46:10 +02:00
parent 7f203a7117
commit 291c8db471
11 changed files with 565 additions and 118 deletions
+140
View File
@@ -0,0 +1,140 @@
# Generated by Pi coding agent
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def forwards(apps, schema_editor):
User = apps.get_model(*settings.AUTH_USER_MODEL.split('.'))
Workspace = apps.get_model('core', 'Workspace')
WorkspaceMembership = apps.get_model('core', 'WorkspaceMembership')
Tag = apps.get_model('core', 'Tag')
Team = apps.get_model('core', 'Team')
Item = apps.get_model('core', 'Item')
Collection = apps.get_model('core', 'Collection')
Kanban = apps.get_model('core', 'Kanban')
ApiKey = apps.get_model('core', 'ApiKey')
personal = {}
for user in User.objects.all():
full_name = f'{getattr(user, "first_name", "")} {getattr(user, "last_name", "")}'.strip()
name = full_name or getattr(user, 'username', '') or f'User {user.pk}'
workspace, _ = Workspace.objects.get_or_create(slug=f'user-{user.pk}', defaults={'name': name, 'owner_id': user.pk})
WorkspaceMembership.objects.get_or_create(workspace=workspace, user=user, defaults={'role': 'owner'})
personal[user.pk] = workspace
def workspace_for_user(user_id):
if user_id not in personal:
user = User.objects.get(pk=user_id)
workspace, _ = Workspace.objects.get_or_create(slug=f'user-{user.pk}', defaults={'name': getattr(user, 'username', '') or f'User {user.pk}', 'owner_id': user.pk})
WorkspaceMembership.objects.get_or_create(workspace=workspace, user=user, defaults={'role': 'owner'})
personal[user_id] = workspace
return personal[user_id]
for team in Team.objects.select_related('owner'):
team.workspace = workspace_for_user(team.owner_id)
team.save(update_fields=['workspace'])
for item in Item.objects.select_related('owner', 'team'):
item.workspace = item.team.workspace if item.team_id and item.team.workspace_id else workspace_for_user(item.owner_id)
item.save(update_fields=['workspace'])
for collection in Collection.objects.select_related('owner', 'team'):
collection.workspace = collection.team.workspace if collection.team_id and collection.team.workspace_id else workspace_for_user(collection.owner_id)
collection.save(update_fields=['workspace'])
for kanban in Kanban.objects.select_related('owner'):
kanban.workspace = workspace_for_user(kanban.owner_id)
kanban.save(update_fields=['workspace'])
for api_key in ApiKey.objects.select_related('owner'):
api_key.workspace = workspace_for_user(api_key.owner_id)
api_key.save(update_fields=['workspace'])
fallback_workspace = next(iter(personal.values()), None)
for tag in list(Tag.objects.all()):
workspace_ids = set(tag.items.exclude(workspace__isnull=True).values_list('workspace_id', flat=True))
workspace_ids.update(tag.tag_collections.exclude(workspace__isnull=True).values_list('workspace_id', flat=True))
workspace_ids.update(tag.kanbans.exclude(workspace__isnull=True).values_list('workspace_id', flat=True))
workspace_ids.update(tag.api_keys.exclude(workspace__isnull=True).values_list('workspace_id', flat=True))
workspace_ids.update(Team.objects.filter(slug=tag.name).exclude(workspace__isnull=True).values_list('workspace_id', flat=True))
if not workspace_ids and fallback_workspace:
workspace_ids.add(fallback_workspace.pk)
if not workspace_ids:
tag.delete()
continue
first = True
for workspace_id in sorted(workspace_ids):
if first:
scoped_tag = tag
scoped_tag.workspace_id = workspace_id
scoped_tag.save(update_fields=['workspace'])
first = False
else:
scoped_tag, _ = Tag.objects.get_or_create(workspace_id=workspace_id, name=tag.name)
for item in list(tag.items.filter(workspace_id=workspace_id)):
item.tags.remove(tag)
item.tags.add(scoped_tag)
for collection in list(tag.tag_collections.filter(workspace_id=workspace_id)):
collection.filter_tags.remove(tag)
collection.filter_tags.add(scoped_tag)
for api_key in list(tag.api_keys.filter(workspace_id=workspace_id)):
api_key.tags.remove(tag)
api_key.tags.add(scoped_tag)
Kanban.objects.filter(workspace_id=workspace_id, tag=tag).update(tag=scoped_tag)
for team in Team.objects.exclude(workspace__isnull=True):
Tag.objects.get_or_create(workspace=team.workspace, name=team.slug)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('core', '0021_collection_type_filters'),
]
operations = [
migrations.CreateModel(
name='Workspace',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120)),
('slug', models.SlugField(allow_unicode=True, max_length=80, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_workspaces', to=settings.AUTH_USER_MODEL)),
],
options={'ordering': ['name']},
),
migrations.CreateModel(
name='WorkspaceMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Member')], default='member', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_memberships', to=settings.AUTH_USER_MODEL)),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='core.workspace')),
],
options={'unique_together': {('workspace', 'user')}},
),
migrations.AddField('tag', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='core.workspace')),
migrations.AlterField('tag', 'name', models.SlugField(allow_unicode=True, max_length=80)),
migrations.AddField('team', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='core.workspace')),
migrations.AlterField('team', 'slug', models.SlugField(allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos', max_length=80)),
migrations.AddField('item', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.workspace')),
migrations.AddField('collection', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='collections', to='core.workspace')),
migrations.AddField('kanban', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kanbans', to='core.workspace')),
migrations.AddField('apikey', 'workspace', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to='core.workspace')),
migrations.RunPython(forwards, migrations.RunPython.noop),
migrations.AlterField('tag', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='core.workspace')),
migrations.AlterField('team', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='core.workspace')),
migrations.AlterField('item', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.workspace')),
migrations.AlterField('collection', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collections', to='core.workspace')),
migrations.AlterField('kanban', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kanbans', to='core.workspace')),
migrations.AlterField('apikey', 'workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to='core.workspace')),
migrations.AddConstraint('tag', models.UniqueConstraint(fields=('workspace', 'name'), name='unique_workspace_tag_name')),
migrations.AddConstraint('team', models.UniqueConstraint(fields=('workspace', 'slug'), name='unique_workspace_team_slug')),
]