diff --git a/core/admin.py b/core/admin.py index f958f7b..af0357f 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference +from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, Workspace, WorkspaceMembership class CollectionSectionInline(admin.TabularInline): @@ -42,3 +42,5 @@ admin.site.register(TeamMembership) admin.site.register(TeamInvite) admin.site.register(Kanban) admin.site.register(UserPreference) +admin.site.register(Workspace) +admin.site.register(WorkspaceMembership) diff --git a/core/context_processors.py b/core/context_processors.py index 3c883b5..565101b 100644 --- a/core/context_processors.py +++ b/core/context_processors.py @@ -1,7 +1,22 @@ from django.urls import reverse from django.utils.translation import gettext as _ -from .models import UserPreference +from .models import UserPreference, WorkspaceMembership, get_or_create_personal_workspace + + +def active_workspace_navigation(request): + if not request.user.is_authenticated: + return {'active_workspace': None, 'workspace_memberships': []} + workspace = get_or_create_personal_workspace(request.user) + workspace_id = request.session.get('workspace_id') + if workspace_id: + membership = WorkspaceMembership.objects.select_related('workspace').filter(user=request.user, workspace_id=workspace_id).first() + if membership: + workspace = membership.workspace + return { + 'active_workspace': workspace, + 'workspace_memberships': WorkspaceMembership.objects.filter(user=request.user).select_related('workspace').order_by('workspace__name'), + } def overview_navigation(request): diff --git a/core/forms.py b/core/forms.py index f1fb5ef..c572c04 100644 --- a/core/forms.py +++ b/core/forms.py @@ -3,7 +3,7 @@ from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.models import User from django.db.models import Q from django.utils.translation import gettext_lazy as _ -from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference +from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference, Workspace, get_or_create_personal_workspace class MultipleFileInput(forms.ClearableFileInput): @@ -73,6 +73,20 @@ class UserPreferenceForm(forms.ModelForm): } +class WorkspaceForm(forms.ModelForm): + class Meta: + model = Workspace + fields = ['name', 'slug'] + labels = {'name': _('Workspace name'), 'slug': _('Short name')} + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Company or workspace name')}), + 'slug': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'firma'}), + } + + def clean_slug(self): + return self.cleaned_data['slug'].strip().lower() + + class UserInviteForm(forms.ModelForm): email = forms.EmailField(required=True, label=_('Email'), widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'person@example.com', 'required': True})) @@ -96,6 +110,11 @@ class InviteRegistrationForm(UserCreationForm): class TeamForm(forms.ModelForm): + def __init__(self, *args, user=None, workspace=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.workspace = workspace or (get_or_create_personal_workspace(user) if user and user.is_authenticated else None) + class Meta: model = Team fields = ['name', 'slug'] @@ -106,7 +125,15 @@ class TeamForm(forms.ModelForm): } def clean_slug(self): - return self.cleaned_data['slug'].strip().lstrip('#').lower() + slug = self.cleaned_data['slug'].strip().lstrip('#').lower() + if self.workspace and Team.objects.filter(workspace=self.workspace, slug=slug).exclude(pk=self.instance.pk).exists(): + raise forms.ValidationError(_('This team tag is already used in this workspace.')) + return slug + + def save(self, commit=True): + if self.workspace: + self.instance.workspace = self.workspace + return super().save(commit=commit) class TeamInviteForm(forms.ModelForm): @@ -120,6 +147,11 @@ class TeamInviteForm(forms.ModelForm): class KanbanForm(forms.ModelForm): tag_name = forms.CharField(label=_('Tag'), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'})) + def __init__(self, *args, user=None, workspace=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.workspace = workspace or (get_or_create_personal_workspace(user) if user and user.is_authenticated else None) + class Meta: model = Kanban fields = ['name', 'tag_name'] @@ -127,7 +159,8 @@ class KanbanForm(forms.ModelForm): def save(self, commit=True): tag_name = self.cleaned_data['tag_name'].strip().lstrip('#').lower() - tag, _ = Tag.objects.get_or_create(name=tag_name) + tag, _ = Tag.objects.get_or_create(workspace=self.workspace, name=tag_name) + self.instance.workspace = self.workspace self.instance.tag = tag return super().save(commit=commit) @@ -135,19 +168,20 @@ class KanbanForm(forms.ModelForm): class CollectionForm(forms.ModelForm): team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, label=_('Team'), widget=forms.Select(attrs={'class': 'form-select'})) - def __init__(self, *args, user=None, **kwargs): + def __init__(self, *args, user=None, workspace=None, **kwargs): super().__init__(*args, **kwargs) self.fields['visibility'].choices = [ (Item.Visibility.PRIVATE, _('Private')), (Item.Visibility.PUBLIC, _('Public')), ] if user and user.is_authenticated: - team_ids = user.team_memberships.values_list('team_id', flat=True) - self.fields['team'].queryset = Team.objects.filter(pk__in=team_ids).order_by('name') + workspace = self.instance.workspace if self.instance and self.instance.pk else (workspace or get_or_create_personal_workspace(user)) + team_ids = user.team_memberships.filter(team__workspace=workspace).values_list('team_id', flat=True) + self.fields['team'].queryset = Team.objects.filter(workspace=workspace, pk__in=team_ids).order_by('name') self.fields['team'].initial = self.instance.team_id if self.instance and self.instance.pk else None - item_scope = Q(items__owner=user) | Q(items__team_id__in=team_ids) - team_slugs = Team.objects.values_list('slug', flat=True) - self.fields['filter_tags'].queryset = Tag.objects.filter(item_scope).exclude(name__in=team_slugs).distinct().order_by('name') + item_scope = Q(items__workspace=workspace) & (Q(items__owner=user) | Q(items__team_id__in=team_ids)) + team_slugs = Team.objects.filter(workspace=workspace).values_list('slug', flat=True) + self.fields['filter_tags'].queryset = Tag.objects.filter(workspace=workspace).filter(item_scope).exclude(name__in=team_slugs).distinct().order_by('name') else: self.fields['team'].queryset = Team.objects.none() self.fields['filter_tags'].queryset = Tag.objects.none() @@ -189,10 +223,11 @@ class CollectionForm(forms.ModelForm): class ApiKeyForm(forms.ModelForm): - def __init__(self, *args, user=None, **kwargs): + def __init__(self, *args, user=None, workspace=None, **kwargs): super().__init__(*args, **kwargs) if user and user.is_authenticated: - self.fields['tags'].queryset = Tag.objects.filter(items__owner=user).distinct().order_by('name') + workspace = workspace or get_or_create_personal_workspace(user) + self.fields['tags'].queryset = Tag.objects.filter(workspace=workspace, items__owner=user).distinct().order_by('name') else: self.fields['tags'].queryset = Tag.objects.none() diff --git a/core/migrations/0022_workspace_tenancy.py b/core/migrations/0022_workspace_tenancy.py new file mode 100644 index 0000000..fe5454a --- /dev/null +++ b/core/migrations/0022_workspace_tenancy.py @@ -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')), + ] diff --git a/core/models.py b/core/models.py index e583a95..2aa89a3 100644 --- a/core/models.py +++ b/core/models.py @@ -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) diff --git a/core/templates/core/base.html b/core/templates/core/base.html index 9a168b5..e2e72dd 100644 --- a/core/templates/core/base.html +++ b/core/templates/core/base.html @@ -30,7 +30,12 @@
Start{% if user.is_authenticated %}{% trans "Journal" %}{% trans "Teams" %}{% endif %}
{% if user.is_authenticated %} - + {% if 'IchWillHeimWebView' in request.META.HTTP_USER_AGENT %}×{% endif %} {% else %}{% trans "Login" %}{% endif %} diff --git a/core/templates/core/settings.html b/core/templates/core/settings.html index d09094c..58c9502 100644 --- a/core/templates/core/settings.html +++ b/core/templates/core/settings.html @@ -9,6 +9,7 @@ @@ -58,6 +59,21 @@ +
+

{% trans "Workspaces" %}

+

{% trans "Separate private and company data into workspaces." %}

+
+ {% for membership in workspace_memberships %}
{{ membership.workspace.name }}{% if active_workspace.id == membership.workspace.id %}{% trans "active" %}{% endif %}
{{ membership.get_role_display }} · {{ membership.workspace.slug }}
{% csrf_token %}
{% endfor %} +
+
+ {% csrf_token %} +
{{ workspace_form.name }}
+
{{ workspace_form.slug }}
+
+ {% if workspace_form.errors %}
{{ workspace_form.errors }}
{% endif %} +
+
+

{% trans "User invitations" %}

{% trans "Invite new users during the test phase. Only people with an invitation link can register." %}

@@ -73,7 +89,7 @@

{% trans "API keys" %}

-

{% trans "Create API keys for integrations and optionally restrict them to tags." %}

+

{% blocktrans with workspace=active_workspace.name %}Create API keys for integrations in workspace {{ workspace }} and optionally restrict them to tags.{% endblocktrans %}

{% csrf_token %}
{{ key_form.name }}
diff --git a/core/tests.py b/core/tests.py index 18634e4..5ebd4a8 100644 --- a/core/tests.py +++ b/core/tests.py @@ -9,7 +9,7 @@ from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone -from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference +from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference, Workspace, WorkspaceMembership, get_or_create_personal_workspace from .templatetags.markdown_extras import markdown, markdown_title from .views import parse_due_command, parse_quick_content @@ -40,6 +40,83 @@ class ItemMetadataTests(TestCase): self.assertFalse(Tag.objects.filter(name='new').exists()) +class WorkspaceTenancyTests(TestCase): + def test_same_tag_name_is_scoped_per_personal_workspace(self): + first_user = get_user_model().objects.create_user(username='tenant-a', password='secret') + second_user = get_user_model().objects.create_user(username='tenant-b', password='secret') + first = Item.objects.create(owner=first_user, content='One #shared') + first.sync_metadata() + second = Item.objects.create(owner=second_user, content='Two #shared') + second.sync_metadata() + + self.assertEqual(Tag.objects.filter(name='shared').count(), 2) + self.assertNotEqual(first.tags.get(name='shared').workspace_id, second.tags.get(name='shared').workspace_id) + + def test_same_team_tag_is_allowed_in_different_workspaces(self): + first_user = get_user_model().objects.create_user(username='team-a', password='secret') + second_user = get_user_model().objects.create_user(username='team-b', password='secret') + + first = Team.objects.create(owner=first_user, name='Docs A', slug='docs') + second = Team.objects.create(owner=second_user, name='Docs B', slug='docs') + + self.assertNotEqual(first.workspace_id, second.workspace_id) + + def test_workspace_can_be_created_and_becomes_active(self): + user = get_user_model().objects.create_user(username='workspace-create', password='secret') + self.client.force_login(user) + + response = self.client.post(reverse('settings'), {'action': 'workspace', 'name': 'Acme Inc', 'slug': 'acme'}) + + workspace = Workspace.objects.get(slug='acme') + self.assertRedirects(response, reverse('settings')) + self.assertEqual(self.client.session['workspace_id'], workspace.id) + self.assertTrue(WorkspaceMembership.objects.filter(workspace=workspace, user=user, role=WorkspaceMembership.Role.OWNER).exists()) + + def test_workspace_switch_changes_overview_scope(self): + user = get_user_model().objects.create_user(username='workspace-switch', password='secret') + personal = get_or_create_personal_workspace(user) + company = Workspace.objects.create(owner=user, name='Company', slug='company') + WorkspaceMembership.objects.create(workspace=company, user=user, role=WorkspaceMembership.Role.OWNER) + Item.objects.create(workspace=personal, owner=user, content='Personal item') + Item.objects.create(workspace=company, owner=user, content='Company item') + self.client.force_login(user) + + response = self.client.post(reverse('workspace_switch', args=[company.id]), {'next': reverse('index')}) + self.assertRedirects(response, reverse('index')) + response = self.client.get(reverse('index')) + + self.assertContains(response, 'Company item') + self.assertNotContains(response, 'Personal item') + + def test_workspace_public_item_is_visible_to_workspace_member_only(self): + owner = get_user_model().objects.create_user(username='workspace-owner', password='secret') + member = get_user_model().objects.create_user(username='workspace-member', password='secret') + outsider = get_user_model().objects.create_user(username='workspace-outsider', password='secret') + workspace = Workspace.objects.create(owner=owner, name='Company', slug='company-public') + WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER) + WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER) + Item.objects.create(workspace=workspace, owner=owner, content='Workspace public', visibility=Item.Visibility.PUBLIC) + + self.client.force_login(member) + self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')}) + self.assertContains(self.client.get(reverse('index')), 'Workspace public') + + self.client.force_login(outsider) + self.assertNotContains(self.client.get(reverse('index')), 'Workspace public') + + def test_authenticated_overview_is_limited_to_current_workspace(self): + user = get_user_model().objects.create_user(username='tenant-visible', password='secret') + other = get_user_model().objects.create_user(username='tenant-other', password='secret') + own = Item.objects.create(owner=user, content='Own item') + public_other = Item.objects.create(owner=other, content='Public other item', visibility=Item.Visibility.PUBLIC) + self.client.force_login(user) + + response = self.client.get(reverse('index')) + + self.assertContains(response, 'Own item') + self.assertNotContains(response, 'Public other item') + + class DefaultItemKindTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user(username='tester', password='secret') @@ -262,7 +339,7 @@ class NavigationAndLayoutTests(TestCase): self.assertNotIn('#noteonly', filter_tags) self.assertIn("'openonly'", html) self.assertIn("'noteonly'", html) - self.assertIn("'publiconly'", html) + self.assertNotIn('publiconly', html) self.assertNotIn('foreignonly', html) response = self.client.get(f'{reverse("tag_list")}?status=archive') @@ -283,7 +360,7 @@ class NavigationAndLayoutTests(TestCase): self.assertEqual(response.status_code, 200) tags = response.json()['tags'] self.assertIn('ownapi', tags) - self.assertIn('publicapi', tags) + self.assertNotIn('publicapi', tags) self.assertNotIn('foreignapi', tags) def test_due_filter_also_shows_next_seven_days_separately(self): @@ -482,7 +559,7 @@ class CollectionTests(TestCase): def test_collection_form_separates_teams_and_content_tags(self): team = Team.objects.create(name='Docs team', slug='docs', owner=self.user) TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER) - Tag.objects.create(name='docs') + Tag.objects.create(workspace=get_or_create_personal_workspace(self.user), name='docs') note = Item.objects.create(owner=self.user, content='#ordinary') note.sync_metadata() response = self.client.get(reverse('collection_create')) @@ -801,15 +878,14 @@ class CollectionTests(TestCase): self.assertRedirects(response, manage_url) self.assertTrue(parent.sections.filter(sub_collection=child).exists()) - def test_public_collection_is_visible_without_login(self): + def test_public_collection_is_workspace_public_not_anonymous(self): collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC) self.client.logout() response = self.client.get(collection.get_absolute_url()) - self.assertEqual(response.status_code, 200) - self.assertNotContains(response, reverse('collection_manage', args=[collection.id])) + self.assertEqual(response.status_code, 404) def test_collection_tag_is_not_removed_as_unused(self): - tag = Tag.objects.create(name='documentation') + tag = Tag.objects.create(workspace=get_or_create_personal_workspace(self.user), name='documentation') collection = Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS) collection.filter_tags.add(tag) note = Item.objects.create(owner=self.user, content='#temporary') @@ -835,8 +911,9 @@ class CollectionTests(TestCase): class ApiItemFilterTests(TestCase): def setUp(self): self.user = get_user_model().objects.create_user(username='api-filter', password='secret') - self.help_tag = Tag.objects.create(name='help') - self.api_key = ApiKey.objects.create(owner=self.user, name='Help CLI') + self.workspace = get_or_create_personal_workspace(self.user) + self.help_tag = Tag.objects.create(workspace=self.workspace, name='help') + self.api_key = ApiKey.objects.create(workspace=self.workspace, owner=self.user, name='Help CLI') self.api_key.tags.add(self.help_tag) def create_item(self, content): diff --git a/core/urls.py b/core/urls.py index 2e6709f..246d6a7 100644 --- a/core/urls.py +++ b/core/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ path('default-item-kind/', views.set_default_item_kind, name='set_default_item_kind'), path('settings/', views.settings_view, name='settings'), path('settings/api-keys//delete/', views.delete_api_key, name='delete_api_key'), + path('workspaces//switch/', views.workspace_switch, name='workspace_switch'), path('settings/invites//delete/', views.delete_user_invite, name='delete_user_invite'), path('teams/', views.team_list, name='team_list'), path('teams//', views.team_detail, name='team_detail'), diff --git a/core/views.py b/core/views.py index 0794591..29a214e 100644 --- a/core/views.py +++ b/core/views.py @@ -20,8 +20,8 @@ from django.utils.translation import gettext as _ from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods, require_POST -from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm -from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, TAG_RE, without_markdown_code +from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm, WorkspaceForm +from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, Workspace, WorkspaceMembership, TAG_RE, get_or_create_personal_workspace, without_markdown_code from .templatetags.markdown_extras import strip_tags_outside_code TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I) @@ -188,14 +188,51 @@ def attach_files_and_replace_tokens(item, files): item.save(update_fields=['content', 'comment', 'updated_at']) -def user_team_ids(user): +def current_workspace(user, api_key=None, workspace=None): + if api_key: + return api_key.workspace + if workspace: + return workspace + if user.is_authenticated: + return get_or_create_personal_workspace(user) + return None + + +def user_workspaces(user): if not user.is_authenticated: return [] - return list(user.team_memberships.values_list('team_id', flat=True)) + return WorkspaceMembership.objects.filter(user=user).select_related('workspace').order_by('workspace__name') -def visible_items(user, api_key=None): - qs = Item.objects.select_related('owner', 'team').prefetch_related('tags', 'attachments', 'linked_items') +def active_workspace(request): + if not request.user.is_authenticated: + return None + workspace_id = request.session.get('workspace_id') + if workspace_id: + membership = WorkspaceMembership.objects.select_related('workspace').filter(user=request.user, workspace_id=workspace_id).first() + if membership: + return membership.workspace + workspace = get_or_create_personal_workspace(request.user) + request.session['workspace_id'] = workspace.id + return workspace + + +def user_team_ids(user, workspace=None): + if not user.is_authenticated: + return [] + qs = user.team_memberships + if workspace: + qs = qs.filter(team__workspace=workspace) + return list(qs.values_list('team_id', flat=True)) + + +def visible_items(user, api_key=None, workspace=None): + workspace = current_workspace(user, api_key, workspace) + qs = Item.objects.select_related('owner', 'team', 'workspace').prefetch_related('tags', 'attachments', 'linked_items') + if workspace: + qs = qs.filter(workspace=workspace) + elif not user.is_authenticated: + return qs.none() if api_key: qs = qs.filter(owner=api_key.owner) allowed_tags = api_key.tags.all() @@ -203,19 +240,20 @@ def visible_items(user, api_key=None): qs = qs.filter(tags__in=allowed_tags).distinct() return qs if user.is_authenticated: - return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user)) | Q(visibility=Item.Visibility.PUBLIC)).distinct() - return qs.filter(visibility=Item.Visibility.PUBLIC) + return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user, workspace)) | Q(visibility=Item.Visibility.PUBLIC)).distinct() + return qs.none() def user_can_edit_item(user, item): return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user).exists()) -def visible_tags(user): +def visible_tags(user, workspace=None): """Tags from items the user may see; avoids leaking private tags from other users.""" + workspace = current_workspace(user, workspace=workspace) if not user.is_authenticated: - return Tag.objects.filter(items__visibility=Item.Visibility.PUBLIC).distinct().order_by('name') - return Tag.objects.filter(items__in=visible_items(user)).distinct().order_by('name') + return Tag.objects.none() + return Tag.objects.filter(workspace=workspace, items__in=visible_items(user, workspace=workspace)).distinct().order_by('name') def user_can_delete_item(user, item): @@ -228,7 +266,7 @@ def desired_item_scope(item, user, force_private=False): if item.visibility == Item.Visibility.PUBLIC: return Item.Visibility.PUBLIC, None tag_names = {match.group(1).lower() for match in re.finditer(r'(?