Introduce workspace tenancy boundaries
This commit is contained in:
+86
-8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user