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
+86 -8
View File
@@ -35,17 +35,63 @@ def without_markdown_code(text):
def delete_unused_tags(tags=None):
"""Delete tags that are no longer used by items or tag-based features."""
candidates = Tag.objects.all() if tags is None else Tag.objects.filter(pk__in=[tag.pk for tag in tags])
candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True).exclude(
name__in=Team.objects.values_list('slug', flat=True),
).delete()
for tag in candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True):
if not Team.objects.filter(workspace=tag.workspace, slug=tag.name).exists():
tag.delete()
class Tag(models.Model):
name = models.SlugField(max_length=80, unique=True, allow_unicode=True)
class Workspace(models.Model):
name = models.CharField(max_length=120)
slug = models.SlugField(max_length=80, unique=True, allow_unicode=True)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_workspaces')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class WorkspaceMembership(models.Model):
class Role(models.TextChoices):
OWNER = 'owner', 'Owner'
ADMIN = 'admin', 'Admin'
MEMBER = 'member', _('Member')
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='workspace_memberships')
role = models.CharField(max_length=10, choices=Role.choices, default=Role.MEMBER)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [('workspace', 'user')]
def __str__(self):
return f'{self.user} in {self.workspace}'
def personal_workspace_slug(user):
return f'user-{user.pk}'
def get_or_create_personal_workspace(user):
workspace, _created = Workspace.objects.get_or_create(
slug=personal_workspace_slug(user),
defaults={'owner': user, 'name': user.get_full_name() or user.get_username() or f'User {user.pk}'},
)
WorkspaceMembership.objects.get_or_create(workspace=workspace, user=user, defaults={'role': WorkspaceMembership.Role.OWNER})
return workspace
class Tag(models.Model):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='tags')
name = models.SlugField(max_length=80, allow_unicode=True)
class Meta:
ordering = ['name']
constraints = [models.UniqueConstraint(fields=['workspace', 'name'], name='unique_workspace_tag_name')]
def __str__(self):
return f'#{self.name}'
@@ -70,13 +116,20 @@ class UserInvite(models.Model):
class Team(models.Model):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='teams')
name = models.CharField(max_length=120)
slug = models.SlugField(max_length=80, unique=True, allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos')
slug = models.SlugField(max_length=80, allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos')
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_teams')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
constraints = [models.UniqueConstraint(fields=['workspace', 'slug'], name='unique_workspace_team_slug')]
def save(self, *args, **kwargs):
if not self.workspace_id and self.owner_id:
self.workspace = get_or_create_personal_workspace(self.owner)
super().save(*args, **kwargs)
def __str__(self):
return self.name
@@ -132,6 +185,7 @@ class Item(models.Model):
TEAM = 'team', _('Team')
PUBLIC = 'public', _('Public')
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='items')
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='items')
team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='items')
kind = models.CharField(max_length=10, choices=Kind.choices, default=Kind.NOTE)
@@ -170,18 +224,26 @@ class Item(models.Model):
def is_due_today(self):
return self.kind == self.Kind.TODO and not self.is_done and self.due_at and timezone.localdate(self.due_at) == timezone.localdate()
def save(self, *args, **kwargs):
if not self.workspace_id and self.owner_id:
self.workspace = get_or_create_personal_workspace(self.owner)
if self.team_id and self.team.workspace_id != self.workspace_id:
self.workspace = self.team.workspace
super().save(*args, **kwargs)
def sync_metadata(self):
old_tags = list(self.tags.all())
metadata_text = without_markdown_code(self.content)
tag_names = {m.group(1).lower() for m in TAG_RE.finditer(metadata_text)}
tags = [Tag.objects.get_or_create(name=name)[0] for name in tag_names]
tags = [Tag.objects.get_or_create(workspace=self.workspace, name=name)[0] for name in tag_names]
self.tags.set(tags)
delete_unused_tags(old_tags)
ids = [int(m.group(2)) for m in LINK_RE.finditer(f'{self.content}\n{self.comment}')]
self.linked_items.set(Item.objects.filter(id__in=ids).exclude(id=self.id))
self.linked_items.set(Item.objects.filter(workspace=self.workspace, id__in=ids).exclude(id=self.id))
class Kanban(models.Model):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='kanbans')
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='kanbans')
name = models.CharField(max_length=120)
tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name='kanbans')
@@ -191,6 +253,11 @@ class Kanban(models.Model):
ordering = ['name']
unique_together = [('owner', 'tag')]
def save(self, *args, **kwargs):
if not self.workspace_id and self.owner_id:
self.workspace = get_or_create_personal_workspace(self.owner)
super().save(*args, **kwargs)
def __str__(self):
return self.name
@@ -208,6 +275,7 @@ class Collection(models.Model):
def is_structured(self):
return self.mode in self.STRUCTURED_MODES
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='collections')
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='collections')
team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='collections')
title = models.CharField(max_length=200)
@@ -225,6 +293,13 @@ class Collection(models.Model):
class Meta:
ordering = ['-updated_at']
def save(self, *args, **kwargs):
if not self.workspace_id and self.owner_id:
self.workspace = get_or_create_personal_workspace(self.owner)
if self.team_id and self.team.workspace_id != self.workspace_id:
self.workspace = self.team.workspace
super().save(*args, **kwargs)
def __str__(self):
return self.title
@@ -285,6 +360,7 @@ class UserPreference(models.Model):
class ApiKey(models.Model):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='api_keys')
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='api_keys')
name = models.CharField(max_length=120, default='API Key')
token = models.CharField(max_length=80, unique=True, editable=False)
@@ -296,6 +372,8 @@ class ApiKey(models.Model):
ordering = ['-created_at']
def save(self, *args, **kwargs):
if not self.workspace_id and self.owner_id:
self.workspace = get_or_create_personal_workspace(self.owner)
if not self.token:
self.token = secrets.token_urlsafe(32)
super().save(*args, **kwargs)