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
+3 -1
View File
@@ -1,5 +1,5 @@
from django.contrib import admin 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): class CollectionSectionInline(admin.TabularInline):
@@ -42,3 +42,5 @@ admin.site.register(TeamMembership)
admin.site.register(TeamInvite) admin.site.register(TeamInvite)
admin.site.register(Kanban) admin.site.register(Kanban)
admin.site.register(UserPreference) admin.site.register(UserPreference)
admin.site.register(Workspace)
admin.site.register(WorkspaceMembership)
+16 -1
View File
@@ -1,7 +1,22 @@
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ 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): def overview_navigation(request):
+46 -11
View File
@@ -3,7 +3,7 @@ from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext_lazy as _ 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): 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): class UserInviteForm(forms.ModelForm):
email = forms.EmailField(required=True, label=_('Email'), widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'person@example.com', 'required': True})) 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): 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: class Meta:
model = Team model = Team
fields = ['name', 'slug'] fields = ['name', 'slug']
@@ -106,7 +125,15 @@ class TeamForm(forms.ModelForm):
} }
def clean_slug(self): 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): class TeamInviteForm(forms.ModelForm):
@@ -120,6 +147,11 @@ class TeamInviteForm(forms.ModelForm):
class KanbanForm(forms.ModelForm): class KanbanForm(forms.ModelForm):
tag_name = forms.CharField(label=_('Tag'), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'})) 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: class Meta:
model = Kanban model = Kanban
fields = ['name', 'tag_name'] fields = ['name', 'tag_name']
@@ -127,7 +159,8 @@ class KanbanForm(forms.ModelForm):
def save(self, commit=True): def save(self, commit=True):
tag_name = self.cleaned_data['tag_name'].strip().lstrip('#').lower() 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 self.instance.tag = tag
return super().save(commit=commit) return super().save(commit=commit)
@@ -135,19 +168,20 @@ class KanbanForm(forms.ModelForm):
class CollectionForm(forms.ModelForm): class CollectionForm(forms.ModelForm):
team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, label=_('Team'), widget=forms.Select(attrs={'class': 'form-select'})) 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) super().__init__(*args, **kwargs)
self.fields['visibility'].choices = [ self.fields['visibility'].choices = [
(Item.Visibility.PRIVATE, _('Private')), (Item.Visibility.PRIVATE, _('Private')),
(Item.Visibility.PUBLIC, _('Public')), (Item.Visibility.PUBLIC, _('Public')),
] ]
if user and user.is_authenticated: if user and user.is_authenticated:
team_ids = user.team_memberships.values_list('team_id', flat=True) workspace = self.instance.workspace if self.instance and self.instance.pk else (workspace or get_or_create_personal_workspace(user))
self.fields['team'].queryset = Team.objects.filter(pk__in=team_ids).order_by('name') 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 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) item_scope = Q(items__workspace=workspace) & (Q(items__owner=user) | Q(items__team_id__in=team_ids))
team_slugs = Team.objects.values_list('slug', flat=True) team_slugs = Team.objects.filter(workspace=workspace).values_list('slug', flat=True)
self.fields['filter_tags'].queryset = Tag.objects.filter(item_scope).exclude(name__in=team_slugs).distinct().order_by('name') self.fields['filter_tags'].queryset = Tag.objects.filter(workspace=workspace).filter(item_scope).exclude(name__in=team_slugs).distinct().order_by('name')
else: else:
self.fields['team'].queryset = Team.objects.none() self.fields['team'].queryset = Team.objects.none()
self.fields['filter_tags'].queryset = Tag.objects.none() self.fields['filter_tags'].queryset = Tag.objects.none()
@@ -189,10 +223,11 @@ class CollectionForm(forms.ModelForm):
class ApiKeyForm(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) super().__init__(*args, **kwargs)
if user and user.is_authenticated: 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: else:
self.fields['tags'].queryset = Tag.objects.none() self.fields['tags'].queryset = Tag.objects.none()
+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')),
]
+86 -8
View File
@@ -35,17 +35,63 @@ def without_markdown_code(text):
def delete_unused_tags(tags=None): def delete_unused_tags(tags=None):
"""Delete tags that are no longer used by items or tag-based features.""" """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 = 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( for tag in candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True):
name__in=Team.objects.values_list('slug', flat=True), if not Team.objects.filter(workspace=tag.workspace, slug=tag.name).exists():
).delete() tag.delete()
class Tag(models.Model): class Workspace(models.Model):
name = models.SlugField(max_length=80, unique=True, allow_unicode=True) 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: class Meta:
ordering = ['name'] 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): def __str__(self):
return f'#{self.name}' return f'#{self.name}'
@@ -70,13 +116,20 @@ class UserInvite(models.Model):
class Team(models.Model): class Team(models.Model):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='teams')
name = models.CharField(max_length=120) 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') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_teams')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
ordering = ['name'] 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): def __str__(self):
return self.name return self.name
@@ -132,6 +185,7 @@ class Item(models.Model):
TEAM = 'team', _('Team') TEAM = 'team', _('Team')
PUBLIC = 'public', _('Public') 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') 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') 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) 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): 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() 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): def sync_metadata(self):
old_tags = list(self.tags.all()) old_tags = list(self.tags.all())
metadata_text = without_markdown_code(self.content) metadata_text = without_markdown_code(self.content)
tag_names = {m.group(1).lower() for m in TAG_RE.finditer(metadata_text)} 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) self.tags.set(tags)
delete_unused_tags(old_tags) delete_unused_tags(old_tags)
ids = [int(m.group(2)) for m in LINK_RE.finditer(f'{self.content}\n{self.comment}')] 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): 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') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='kanbans')
name = models.CharField(max_length=120) name = models.CharField(max_length=120)
tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name='kanbans') tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name='kanbans')
@@ -191,6 +253,11 @@ class Kanban(models.Model):
ordering = ['name'] ordering = ['name']
unique_together = [('owner', 'tag')] 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): def __str__(self):
return self.name return self.name
@@ -208,6 +275,7 @@ class Collection(models.Model):
def is_structured(self): def is_structured(self):
return self.mode in self.STRUCTURED_MODES 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') 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') team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='collections')
title = models.CharField(max_length=200) title = models.CharField(max_length=200)
@@ -225,6 +293,13 @@ class Collection(models.Model):
class Meta: class Meta:
ordering = ['-updated_at'] 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): def __str__(self):
return self.title return self.title
@@ -285,6 +360,7 @@ class UserPreference(models.Model):
class ApiKey(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') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='api_keys')
name = models.CharField(max_length=120, default='API Key') name = models.CharField(max_length=120, default='API Key')
token = models.CharField(max_length=80, unique=True, editable=False) token = models.CharField(max_length=80, unique=True, editable=False)
@@ -296,6 +372,8 @@ class ApiKey(models.Model):
ordering = ['-created_at'] ordering = ['-created_at']
def save(self, *args, **kwargs): 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: if not self.token:
self.token = secrets.token_urlsafe(32) self.token = secrets.token_urlsafe(32)
super().save(*args, **kwargs) super().save(*args, **kwargs)
+6 -1
View File
@@ -30,7 +30,12 @@
</div> </div>
<div class="d-flex d-md-none align-items-center gap-1"><a class="small nav-soft fw-semibold" href="/">Start</a>{% if user.is_authenticated %}<a class="small nav-soft" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small nav-soft" href="{% url 'team_list' %}">{% trans "Teams" %}</a>{% endif %}</div> <div class="d-flex d-md-none align-items-center gap-1"><a class="small nav-soft fw-semibold" href="/">Start</a>{% if user.is_authenticated %}<a class="small nav-soft" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small nav-soft" href="{% url 'team_list' %}">{% trans "Teams" %}</a>{% endif %}</div>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="dropdown ms-auto"><button class="btn btn-sm btn-outline-secondary dropdown-toggle user-menu-btn" data-bs-toggle="dropdown">{{ user.get_full_name|default:user.username }}</button><ul class="dropdown-menu dropdown-menu-end"><li><a class="dropdown-item" href="{% url 'settings' %}">{% trans "Settings" %}</a></li><li><button id="pwa-install" type="button" class="dropdown-item d-none">{% trans "Install app" %}</button></li>{% if user.is_staff %}<li><a class="dropdown-item" href="/admin/">Admin</a></li>{% endif %}<li><hr class="dropdown-divider"></li><li><form method="post" action="{% url 'logout' %}">{% csrf_token %}<button class="dropdown-item">{% trans "Logout" %}</button></form></li></ul></div> <div class="dropdown ms-auto"><button class="btn btn-sm btn-outline-secondary dropdown-toggle user-menu-btn" data-bs-toggle="dropdown">{{ active_workspace.name|default:user.get_full_name|default:user.username }}</button><ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">{% trans "Workspace" %}</h6></li>
{% for membership in workspace_memberships %}<li><form method="post" action="{% url 'workspace_switch' membership.workspace.id %}">{% csrf_token %}<input type="hidden" name="next" value="{{ request.get_full_path }}"><button class="dropdown-item {% if active_workspace.id == membership.workspace.id %}active{% endif %}">{% if active_workspace.id == membership.workspace.id %}✓ {% endif %}{{ membership.workspace.name }}</button></form></li>{% endfor %}
<li><a class="dropdown-item" href="{% url 'settings' %}#workspaces">+ {% trans "Create workspace" %}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'settings' %}">{% trans "Settings" %}</a></li><li><button id="pwa-install" type="button" class="dropdown-item d-none">{% trans "Install app" %}</button></li>{% if user.is_staff %}<li><a class="dropdown-item" href="/admin/">Admin</a></li>{% endif %}<li><hr class="dropdown-divider"></li><li><form method="post" action="{% url 'logout' %}">{% csrf_token %}<button class="dropdown-item">{% trans "Logout" %}</button></form></li></ul></div>
{% if 'IchWillHeimWebView' in request.META.HTTP_USER_AGENT %}<a class="btn btn-sm btn-outline-danger" href="iwh://close" title="{% trans 'Close' %}" aria-label="{% trans 'Close' %}">×</a>{% endif %} {% if 'IchWillHeimWebView' in request.META.HTTP_USER_AGENT %}<a class="btn btn-sm btn-outline-danger" href="iwh://close" title="{% trans 'Close' %}" aria-label="{% trans 'Close' %}">×</a>{% endif %}
{% else %}<a class="btn btn-sm btn-dark ms-auto" href="{% url 'login' %}">{% trans "Login" %}</a>{% endif %} {% else %}<a class="btn btn-sm btn-dark ms-auto" href="{% url 'login' %}">{% trans "Login" %}</a>{% endif %}
</div></div></nav> </div></div></nav>
+17 -1
View File
@@ -9,6 +9,7 @@
<nav class="nav nav-pills flex-column gap-1"> <nav class="nav nav-pills flex-column gap-1">
<a class="nav-link" href="#profile">{% trans "Profile" %}</a> <a class="nav-link" href="#profile">{% trans "Profile" %}</a>
<a class="nav-link" href="#preferences">{% trans "View & language" %}</a> <a class="nav-link" href="#preferences">{% trans "View & language" %}</a>
<a class="nav-link" href="#workspaces">{% trans "Workspaces" %}</a>
<a class="nav-link" href="#invites">{% trans "Invitations" %}</a> <a class="nav-link" href="#invites">{% trans "Invitations" %}</a>
<a class="nav-link" href="#api">{% trans "API keys" %}</a> <a class="nav-link" href="#api">{% trans "API keys" %}</a>
</nav> </nav>
@@ -58,6 +59,21 @@
</form> </form>
</div></div> </div></div>
<div id="workspaces" class="card item-card shadow-sm"><div class="card-body">
<h2 class="h4 mb-1">{% trans "Workspaces" %}</h2>
<p class="text-muted small mb-3">{% trans "Separate private and company data into workspaces." %}</p>
<div class="vstack gap-2 mb-4">
{% for membership in workspace_memberships %}<div class="border rounded p-3 d-flex justify-content-between align-items-center gap-2"><div><strong>{{ membership.workspace.name }}</strong>{% if active_workspace.id == membership.workspace.id %}<span class="badge text-bg-dark ms-2">{% trans "active" %}</span>{% endif %}<div class="small text-muted">{{ membership.get_role_display }} · {{ membership.workspace.slug }}</div></div><form method="post" action="{% url 'workspace_switch' membership.workspace.id %}">{% csrf_token %}<input type="hidden" name="next" value="{% url 'settings' %}#workspaces"><button class="btn btn-sm btn-outline-secondary" {% if active_workspace.id == membership.workspace.id %}disabled{% endif %}>{% trans "Switch" %}</button></form></div>{% endfor %}
</div>
<form method="post" class="row g-3">
{% csrf_token %}<input type="hidden" name="action" value="workspace">
<div class="col-md-6"><label class="form-label">{{ workspace_form.name.label }}</label>{{ workspace_form.name }}</div>
<div class="col-md-4"><label class="form-label">{{ workspace_form.slug.label }}</label>{{ workspace_form.slug }}</div>
<div class="col-md-2 d-flex align-items-end"><button class="btn btn-dark w-100">{% trans "Create" %}</button></div>
{% if workspace_form.errors %}<div class="col-12 text-danger small">{{ workspace_form.errors }}</div>{% endif %}
</form>
</div></div>
<div id="invites" class="card item-card shadow-sm"><div class="card-body"> <div id="invites" class="card item-card shadow-sm"><div class="card-body">
<h2 class="h4 mb-1">{% trans "User invitations" %}</h2> <h2 class="h4 mb-1">{% trans "User invitations" %}</h2>
<p class="text-muted small mb-3">{% trans "Invite new users during the test phase. Only people with an invitation link can register." %}</p> <p class="text-muted small mb-3">{% trans "Invite new users during the test phase. Only people with an invitation link can register." %}</p>
@@ -73,7 +89,7 @@
<div id="api" class="card item-card shadow-sm"><div class="card-body"> <div id="api" class="card item-card shadow-sm"><div class="card-body">
<h2 class="h4 mb-1">{% trans "API keys" %}</h2> <h2 class="h4 mb-1">{% trans "API keys" %}</h2>
<p class="text-muted small mb-3">{% trans "Create API keys for integrations and optionally restrict them to tags." %}</p> <p class="text-muted small mb-3">{% blocktrans with workspace=active_workspace.name %}Create API keys for integrations in workspace {{ workspace }} and optionally restrict them to tags.{% endblocktrans %}</p>
<form method="post" class="vstack gap-3 mb-4"> <form method="post" class="vstack gap-3 mb-4">
{% csrf_token %}<input type="hidden" name="action" value="apikey"> {% csrf_token %}<input type="hidden" name="action" value="apikey">
<div><label class="form-label">{% trans "Name" %}</label>{{ key_form.name }}</div> <div><label class="form-label">{% trans "Name" %}</label>{{ key_form.name }}</div>
+87 -10
View File
@@ -9,7 +9,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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 .templatetags.markdown_extras import markdown, markdown_title
from .views import parse_due_command, parse_quick_content from .views import parse_due_command, parse_quick_content
@@ -40,6 +40,83 @@ class ItemMetadataTests(TestCase):
self.assertFalse(Tag.objects.filter(name='new').exists()) 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): class DefaultItemKindTests(TestCase):
def setUp(self): def setUp(self):
self.user = get_user_model().objects.create_user(username='tester', password='secret') 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.assertNotIn('#noteonly', filter_tags)
self.assertIn("'openonly'", html) self.assertIn("'openonly'", html)
self.assertIn("'noteonly'", html) self.assertIn("'noteonly'", html)
self.assertIn("'publiconly'", html) self.assertNotIn('publiconly', html)
self.assertNotIn('foreignonly', html) self.assertNotIn('foreignonly', html)
response = self.client.get(f'{reverse("tag_list")}?status=archive') response = self.client.get(f'{reverse("tag_list")}?status=archive')
@@ -283,7 +360,7 @@ class NavigationAndLayoutTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
tags = response.json()['tags'] tags = response.json()['tags']
self.assertIn('ownapi', tags) self.assertIn('ownapi', tags)
self.assertIn('publicapi', tags) self.assertNotIn('publicapi', tags)
self.assertNotIn('foreignapi', tags) self.assertNotIn('foreignapi', tags)
def test_due_filter_also_shows_next_seven_days_separately(self): 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): def test_collection_form_separates_teams_and_content_tags(self):
team = Team.objects.create(name='Docs team', slug='docs', owner=self.user) team = Team.objects.create(name='Docs team', slug='docs', owner=self.user)
TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER) 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 = Item.objects.create(owner=self.user, content='#ordinary')
note.sync_metadata() note.sync_metadata()
response = self.client.get(reverse('collection_create')) response = self.client.get(reverse('collection_create'))
@@ -801,15 +878,14 @@ class CollectionTests(TestCase):
self.assertRedirects(response, manage_url) self.assertRedirects(response, manage_url)
self.assertTrue(parent.sections.filter(sub_collection=child).exists()) 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) collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
self.client.logout() self.client.logout()
response = self.client.get(collection.get_absolute_url()) response = self.client.get(collection.get_absolute_url())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 404)
self.assertNotContains(response, reverse('collection_manage', args=[collection.id]))
def test_collection_tag_is_not_removed_as_unused(self): 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 = Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS)
collection.filter_tags.add(tag) collection.filter_tags.add(tag)
note = Item.objects.create(owner=self.user, content='#temporary') note = Item.objects.create(owner=self.user, content='#temporary')
@@ -835,8 +911,9 @@ class CollectionTests(TestCase):
class ApiItemFilterTests(TestCase): class ApiItemFilterTests(TestCase):
def setUp(self): def setUp(self):
self.user = get_user_model().objects.create_user(username='api-filter', password='secret') self.user = get_user_model().objects.create_user(username='api-filter', password='secret')
self.help_tag = Tag.objects.create(name='help') self.workspace = get_or_create_personal_workspace(self.user)
self.api_key = ApiKey.objects.create(owner=self.user, name='Help CLI') 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) self.api_key.tags.add(self.help_tag)
def create_item(self, content): def create_item(self, content):
+1
View File
@@ -17,6 +17,7 @@ urlpatterns = [
path('default-item-kind/', views.set_default_item_kind, name='set_default_item_kind'), path('default-item-kind/', views.set_default_item_kind, name='set_default_item_kind'),
path('settings/', views.settings_view, name='settings'), path('settings/', views.settings_view, name='settings'),
path('settings/api-keys/<int:pk>/delete/', views.delete_api_key, name='delete_api_key'), path('settings/api-keys/<int:pk>/delete/', views.delete_api_key, name='delete_api_key'),
path('workspaces/<int:pk>/switch/', views.workspace_switch, name='workspace_switch'),
path('settings/invites/<int:pk>/delete/', views.delete_user_invite, name='delete_user_invite'), path('settings/invites/<int:pk>/delete/', views.delete_user_invite, name='delete_user_invite'),
path('teams/', views.team_list, name='team_list'), path('teams/', views.team_list, name='team_list'),
path('teams/<int:pk>/', views.team_detail, name='team_detail'), path('teams/<int:pk>/', views.team_detail, name='team_detail'),
+162 -85
View File
@@ -20,8 +20,8 @@ from django.utils.translation import gettext as _
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST 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 .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, TAG_RE, without_markdown_code 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 from .templatetags.markdown_extras import strip_tags_outside_code
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I) 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']) 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: if not user.is_authenticated:
return [] 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): def active_workspace(request):
qs = Item.objects.select_related('owner', 'team').prefetch_related('tags', 'attachments', 'linked_items') 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: if api_key:
qs = qs.filter(owner=api_key.owner) qs = qs.filter(owner=api_key.owner)
allowed_tags = api_key.tags.all() 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() qs = qs.filter(tags__in=allowed_tags).distinct()
return qs return qs
if user.is_authenticated: 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(Q(owner=user) | Q(team_id__in=user_team_ids(user, workspace)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
return qs.filter(visibility=Item.Visibility.PUBLIC) return qs.none()
def user_can_edit_item(user, item): 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()) 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.""" """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: if not user.is_authenticated:
return Tag.objects.filter(items__visibility=Item.Visibility.PUBLIC).distinct().order_by('name') return Tag.objects.none()
return Tag.objects.filter(items__in=visible_items(user)).distinct().order_by('name') return Tag.objects.filter(workspace=workspace, items__in=visible_items(user, workspace=workspace)).distinct().order_by('name')
def user_can_delete_item(user, item): 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: if item.visibility == Item.Visibility.PUBLIC:
return Item.Visibility.PUBLIC, None return Item.Visibility.PUBLIC, None
tag_names = {match.group(1).lower() for match in re.finditer(r'(?<!\w)#([\wäöüÄÖÜß-]+)', without_markdown_code(item.content))} tag_names = {match.group(1).lower() for match in re.finditer(r'(?<!\w)#([\wäöüÄÖÜß-]+)', without_markdown_code(item.content))}
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first() membership = TeamMembership.objects.select_related('team').filter(user=user, team__workspace=item.workspace, team__slug__in=tag_names).first()
if membership: if membership:
return Item.Visibility.TEAM, membership.team return Item.Visibility.TEAM, membership.team
visibility = Item.Visibility.PRIVATE if item.visibility == Item.Visibility.TEAM else item.visibility visibility = Item.Visibility.PRIVATE if item.visibility == Item.Visibility.TEAM else item.visibility
@@ -256,7 +294,7 @@ def apply_team_from_tags(item, user, force_private=False):
item.save(update_fields=['team', 'updated_at']) item.save(update_fields=['team', 'updated_at'])
return return
tag_names = list(item.tags.values_list('name', flat=True)) tag_names = list(item.tags.values_list('name', flat=True))
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first() membership = TeamMembership.objects.select_related('team').filter(user=user, team__workspace=item.workspace, team__slug__in=tag_names).first()
if membership: if membership:
item.team = membership.team item.team = membership.team
if item.visibility == Item.Visibility.PRIVATE: if item.visibility == Item.Visibility.PRIVATE:
@@ -272,7 +310,7 @@ def get_api_key(request):
token = request.headers.get('X-Api-Key') or request.headers.get('Authorization', '').removeprefix('Bearer ').strip() token = request.headers.get('X-Api-Key') or request.headers.get('Authorization', '').removeprefix('Bearer ').strip()
if not token: if not token:
return None return None
return ApiKey.objects.filter(token=token, is_active=True).select_related('owner').prefetch_related('tags').first() return ApiKey.objects.filter(token=token, is_active=True).select_related('owner', 'workspace').prefetch_related('tags').first()
def api_auth_required(view): def api_auth_required(view):
@@ -285,13 +323,13 @@ def api_auth_required(view):
return wrapper return wrapper
def index_filter_tags(request, user, prefs, active_tags=None): def index_filter_tags(request, user, prefs, active_tags=None, workspace=None):
active_tags = active_tags or [] active_tags = active_tags or []
kind = request.GET.get('kind') kind = request.GET.get('kind')
status = request.GET.get('status') status = request.GET.get('status')
day = request.GET.get('day') day = request.GET.get('day')
q = request.GET.get('q', '').strip() q = request.GET.get('q', '').strip()
items = visible_items(user) items = visible_items(user, workspace=workspace)
applied_preferences = False applied_preferences = False
if kind in dict(Item.Kind.choices): if kind in dict(Item.Kind.choices):
items = items.filter(kind=kind) items = items.filter(kind=kind)
@@ -322,7 +360,11 @@ def index_filter_tags(request, user, prefs, active_tags=None):
items = items.exclude(kind=Item.Kind.TODO, is_done=True) items = items.exclude(kind=Item.Kind.TODO, is_done=True)
if q: if q:
items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct() items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
return Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags)).distinct().order_by('name') tags = Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags))
workspace = current_workspace(user, workspace=workspace)
if workspace:
tags = tags.filter(workspace=workspace)
return tags.distinct().order_by('name')
def index_filtered_items(request, prefs): def index_filtered_items(request, prefs):
@@ -333,7 +375,7 @@ def index_filtered_items(request, prefs):
status = request.GET.get('status') status = request.GET.get('status')
day = request.GET.get('day') day = request.GET.get('day')
q = request.GET.get('q', '').strip() q = request.GET.get('q', '').strip()
items = visible_items(request.user) items = visible_items(request.user, workspace=active_workspace(request))
applied_preferences = False applied_preferences = False
if active_tags: if active_tags:
if active_tag_mode == 'all': if active_tag_mode == 'all':
@@ -443,7 +485,7 @@ def collection_sections_export(sections, level=2):
def collection_export(request, pk): def collection_export(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk) collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)), pk=pk)
lines = [f'# {collection.title}', ''] lines = [f'# {collection.title}', '']
if collection.description: if collection.description:
lines.extend([collection.description.strip(), '']) lines.extend([collection.description.strip(), ''])
@@ -471,13 +513,13 @@ def index(request):
active_tag = request.GET.get('tag') active_tag = request.GET.get('tag')
if active_tag and not TAG_RE.search(without_markdown_code(content)): if active_tag and not TAG_RE.search(without_markdown_code(content)):
content = f'{content} #{active_tag.strip().lower()}' content = f'{content} #{active_tag.strip().lower()}'
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at) item = Item.objects.create(workspace=active_workspace(request), owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
attach_files_and_replace_tokens(item, request.FILES.getlist('files')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I))) apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
if request.headers.get('HX-Request'): if request.headers.get('HX-Request'):
active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()] active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()]
response = render(request, 'core/_create_response.html', {'item': item, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags}) response = render(request, 'core/_create_response.html', {'item': item, 'tags': index_filter_tags(request, request.user, prefs, active_tags, workspace=active_workspace(request)), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags})
response['HX-Trigger'] = 'tagsChanged' response['HX-Trigger'] = 'tagsChanged'
return response return response
return redirect('index') return redirect('index')
@@ -493,7 +535,7 @@ def index(request):
query['tag'] = q[1:].strip().lower() query['tag'] = q[1:].strip().lower()
query.pop('q', None) query.pop('q', None)
return redirect(f'{request.path}?{query.urlencode()}') return redirect(f'{request.path}?{query.urlencode()}')
items = visible_items(request.user) items = visible_items(request.user, workspace=active_workspace(request))
applied_preferences = False applied_preferences = False
if active_tags: if active_tags:
if active_tag_mode == 'all': if active_tag_mode == 'all':
@@ -540,7 +582,7 @@ def index(request):
default=Value(1), output_field=IntegerField(), default=Value(1), output_field=IntegerField(),
)).order_by('due_rank', 'due_at', '-created_at') )).order_by('due_rank', 'due_at', '-created_at')
return render(request, 'core/index.html', { return render(request, 'core/index.html', {
'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'composer_tags': visible_tags(request.user), 'item_kind_choices': Item.Kind.choices, 'selected_item_kind': prefs.default_item_kind, 'due_now_items': due_now_items, 'upcoming_due_items': upcoming_due_items, 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines, 'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags, workspace=active_workspace(request)), 'composer_tags': visible_tags(request.user, workspace=active_workspace(request)), 'item_kind_choices': Item.Kind.choices, 'selected_item_kind': prefs.default_item_kind, 'due_now_items': due_now_items, 'upcoming_due_items': upcoming_due_items, 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines,
}) })
@@ -554,7 +596,7 @@ def new_item(request):
collection = None collection = None
collection_id = request.GET.get('collection') or request.POST.get('collection') collection_id = request.GET.get('collection') or request.POST.get('collection')
if collection_id: if collection_id:
collection = get_object_or_404(visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES), pk=collection_id) collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)).filter(mode__in=Collection.STRUCTURED_MODES), pk=collection_id)
if not user_can_edit_collection(request.user, collection): if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
requested_kind = Item.Kind.NOTE requested_kind = Item.Kind.NOTE
@@ -566,7 +608,7 @@ def new_item(request):
raw = f'/{requested_kind} {raw}' raw = f'/{requested_kind} {raw}'
raw, due_at, _ = parse_due_command(raw) raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw, requested_default_item_kind(request.POST, prefs, save=True)) kind, visibility, content, url = parse_quick_content(raw, requested_default_item_kind(request.POST, prefs, save=True))
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at) item = Item.objects.create(workspace=active_workspace(request), owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
attach_files_and_replace_tokens(item, request.FILES.getlist('files')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I))) apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
@@ -575,7 +617,7 @@ def new_item(request):
add_collection_section(collection, item) add_collection_section(collection, item)
return redirect(next_url) return redirect(next_url)
return render(request, 'core/new_item.html', { return render(request, 'core/new_item.html', {
'form': QuickItemForm(), 'tags': visible_tags(request.user), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection, 'form': QuickItemForm(), 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection,
}) })
@@ -589,7 +631,7 @@ def journal(request):
raw = f'/journal {raw}' raw = f'/journal {raw}'
raw, due_at, _ = parse_due_command(raw) raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw) kind, visibility, content, url = parse_quick_content(raw)
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at) item = Item.objects.create(workspace=active_workspace(request), owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
attach_files_and_replace_tokens(item, request.FILES.getlist('files')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I))) apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
@@ -602,7 +644,7 @@ def journal(request):
selected = date.fromisoformat(selected_day) selected = date.fromisoformat(selected_day)
year = int(request.GET.get('year', selected.year)) year = int(request.GET.get('year', selected.year))
month = int(request.GET.get('month', selected.month)) month = int(request.GET.get('month', selected.month))
base = visible_items(request.user) base = visible_items(request.user, workspace=active_workspace(request))
journal_entries = base.filter(kind=Item.Kind.JOURNAL, created_at__date=selected) journal_entries = base.filter(kind=Item.Kind.JOURNAL, created_at__date=selected)
created_entries = base.filter(kind__in=[Item.Kind.NOTE, Item.Kind.LINK], created_at__date=selected) created_entries = base.filter(kind__in=[Item.Kind.NOTE, Item.Kind.LINK], created_at__date=selected)
completed_todos = base.filter(kind=Item.Kind.TODO, completed_at__date=selected) completed_todos = base.filter(kind=Item.Kind.TODO, completed_at__date=selected)
@@ -614,7 +656,7 @@ def journal(request):
next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year) next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year)
prefs, created = UserPreference.objects.get_or_create(user=request.user) prefs, created = UserPreference.objects.get_or_create(user=request.user)
return render(request, 'core/journal.html', { return render(request, 'core/journal.html', {
'form': QuickItemForm(), 'items': items, 'tags': visible_tags(request.user), 'weeks': weeks, 'year': year, 'month': month, 'selected_day': selected_day, 'form': QuickItemForm(), 'items': items, 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'weeks': weeks, 'year': year, 'month': month, 'selected_day': selected_day,
'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month, 'overview_lines': prefs.overview_lines, 'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month, 'overview_lines': prefs.overview_lines,
}) })
@@ -638,8 +680,20 @@ def set_user_language(request):
return response return response
@login_required
@require_POST
def workspace_switch(request, pk):
membership = get_object_or_404(WorkspaceMembership, workspace_id=pk, user=request.user)
request.session['workspace_id'] = membership.workspace_id
next_url = request.POST.get('next') or reverse('index')
if not url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
next_url = reverse('index')
return redirect(next_url)
@login_required @login_required
def settings_view(request): def settings_view(request):
workspace = active_workspace(request)
if request.method == 'POST': if request.method == 'POST':
if request.POST.get('action') == 'profile': if request.POST.get('action') == 'profile':
form = UserSettingsForm(request.POST, instance=request.user) form = UserSettingsForm(request.POST, instance=request.user)
@@ -654,10 +708,21 @@ def settings_view(request):
pref_form.save() pref_form.save()
messages.success(request, _('Default view saved.')) messages.success(request, _('Default view saved.'))
return redirect('settings') return redirect('settings')
elif request.POST.get('action') == 'workspace':
workspace_form = WorkspaceForm(request.POST)
if workspace_form.is_valid():
workspace = workspace_form.save(commit=False)
workspace.owner = request.user
workspace.save()
WorkspaceMembership.objects.create(workspace=workspace, user=request.user, role=WorkspaceMembership.Role.OWNER)
request.session['workspace_id'] = workspace.id
messages.success(request, _('Workspace created.'))
return redirect('settings')
elif request.POST.get('action') == 'apikey': elif request.POST.get('action') == 'apikey':
key_form = ApiKeyForm(request.POST, user=request.user) key_form = ApiKeyForm(request.POST, user=request.user, workspace=workspace)
if key_form.is_valid(): if key_form.is_valid():
api_key = key_form.save(commit=False) api_key = key_form.save(commit=False)
api_key.workspace = workspace
api_key.owner = request.user api_key.owner = request.user
api_key.save() api_key.save()
key_form.save_m2m() key_form.save_m2m()
@@ -675,7 +740,9 @@ def settings_view(request):
return render(request, 'core/settings.html', { return render(request, 'core/settings.html', {
'profile_form': UserSettingsForm(instance=request.user), 'profile_form': UserSettingsForm(instance=request.user),
'preference_form': UserPreferenceForm(instance=prefs), 'preference_form': UserPreferenceForm(instance=prefs),
'key_form': ApiKeyForm(user=request.user), 'workspace_form': WorkspaceForm(),
'workspace_memberships': user_workspaces(request.user),
'key_form': ApiKeyForm(user=request.user, workspace=workspace),
'user_invite_form': UserInviteForm(), 'user_invite_form': UserInviteForm(),
'user_invites': request.user.sent_user_invites.filter(accepted_at__isnull=True), 'user_invites': request.user.sent_user_invites.filter(accepted_at__isnull=True),
'api_keys': request.user.api_keys.prefetch_related('tags'), 'api_keys': request.user.api_keys.prefetch_related('tags'),
@@ -728,18 +795,19 @@ def accept_user_invite(request, token):
@login_required @login_required
def team_list(request): def team_list(request):
if request.method == 'POST': if request.method == 'POST':
team_form = TeamForm(request.POST) team_form = TeamForm(request.POST, user=request.user, workspace=active_workspace(request))
if team_form.is_valid(): if team_form.is_valid():
team = team_form.save(commit=False) team = team_form.save(commit=False)
team.owner = request.user team.owner = request.user
team.save() team.save()
TeamMembership.objects.create(team=team, user=request.user, role=TeamMembership.Role.OWNER) TeamMembership.objects.create(team=team, user=request.user, role=TeamMembership.Role.OWNER)
Tag.objects.get_or_create(name=team.slug) Tag.objects.get_or_create(workspace=team.workspace, name=team.slug)
messages.success(request, _('Team %(team)s created. Use #%(slug)s for team items.') % {'team': team.name, 'slug': team.slug}) messages.success(request, _('Team %(team)s created. Use #%(slug)s for team items.') % {'team': team.name, 'slug': team.slug})
return redirect('team_detail', pk=team.pk) return redirect('team_detail', pk=team.pk)
teams = Team.objects.filter(memberships__user=request.user).distinct().prefetch_related('memberships__user') workspace = active_workspace(request)
teams = Team.objects.filter(workspace=workspace, memberships__user=request.user).distinct().prefetch_related('memberships__user')
stats = [] stats = []
base_items = visible_items(request.user) base_items = visible_items(request.user, workspace=active_workspace(request))
for team in teams: for team in teams:
team_items = base_items.filter(team=team) team_items = base_items.filter(team=team)
stats.append({ stats.append({
@@ -749,12 +817,12 @@ def team_list(request):
'open_todos_count': team_items.filter(kind=Item.Kind.TODO, is_done=False).count(), 'open_todos_count': team_items.filter(kind=Item.Kind.TODO, is_done=False).count(),
'recent_items': team_items[:3], 'recent_items': team_items[:3],
}) })
return render(request, 'core/team_list.html', {'stats': stats, 'team_form': TeamForm(), 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4}) return render(request, 'core/team_list.html', {'stats': stats, 'team_form': TeamForm(user=request.user, workspace=active_workspace(request)), 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
@login_required @login_required
def team_detail(request, pk): def team_detail(request, pk):
membership = get_object_or_404(TeamMembership.objects.select_related('team'), team_id=pk, user=request.user) membership = get_object_or_404(TeamMembership.objects.select_related('team'), team_id=pk, user=request.user, team__workspace=active_workspace(request))
team = membership.team team = membership.team
if request.method == 'POST' and membership.role in [TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]: if request.method == 'POST' and membership.role in [TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]:
invite_form = TeamInviteForm(request.POST) invite_form = TeamInviteForm(request.POST)
@@ -765,7 +833,7 @@ def team_detail(request, pk):
invite.save() invite.save()
messages.success(request, _('Invitation link created: %(url)s') % {'url': request.build_absolute_uri(reverse("team_accept_invite", args=[invite.token]))}) messages.success(request, _('Invitation link created: %(url)s') % {'url': request.build_absolute_uri(reverse("team_accept_invite", args=[invite.token]))})
return redirect('team_detail', pk=team.pk) return redirect('team_detail', pk=team.pk)
items = visible_items(request.user).filter(team=team) items = visible_items(request.user, workspace=active_workspace(request)).filter(team=team)
return render(request, 'core/team_detail.html', { return render(request, 'core/team_detail.html', {
'team': team, 'team': team,
'membership': membership, 'membership': membership,
@@ -780,18 +848,23 @@ def team_detail(request, pk):
@login_required @login_required
def team_accept_invite(request, token): def team_accept_invite(request, token):
invite = get_object_or_404(TeamInvite.objects.select_related('team'), token=token, accepted_at__isnull=True) invite = get_object_or_404(TeamInvite.objects.select_related('team'), token=token, accepted_at__isnull=True)
WorkspaceMembership.objects.get_or_create(workspace=invite.team.workspace, user=request.user, defaults={'role': WorkspaceMembership.Role.MEMBER})
TeamMembership.objects.get_or_create(team=invite.team, user=request.user, defaults={'role': TeamMembership.Role.MEMBER}) TeamMembership.objects.get_or_create(team=invite.team, user=request.user, defaults={'role': TeamMembership.Role.MEMBER})
request.session['workspace_id'] = invite.team.workspace_id
invite.accepted_at = timezone.now() invite.accepted_at = timezone.now()
invite.save(update_fields=['accepted_at']) invite.save(update_fields=['accepted_at'])
messages.success(request, _('You are now a member of team %(team)s.') % {'team': invite.team.name}) messages.success(request, _('You are now a member of team %(team)s.') % {'team': invite.team.name})
return redirect('team_detail', pk=invite.team.pk) return redirect('team_detail', pk=invite.team.pk)
def visible_collections(user): def visible_collections(user, workspace=None):
qs = Collection.objects.select_related('owner', 'team').prefetch_related('filter_tags', 'sections__item', 'sections__sub_collection') workspace = current_workspace(user, workspace=workspace)
qs = Collection.objects.select_related('owner', 'team', 'workspace').prefetch_related('filter_tags', 'sections__item', 'sections__sub_collection')
if workspace:
qs = qs.filter(workspace=workspace)
if user.is_authenticated: 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(Q(owner=user) | Q(team_id__in=user_team_ids(user, workspace)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
return qs.filter(visibility=Item.Visibility.PUBLIC) return qs.none()
def user_can_edit_collection(user, collection): def user_can_edit_collection(user, collection):
@@ -844,7 +917,7 @@ def collection_target_from_form(form, user):
def collection_tag_items(collection): def collection_tag_items(collection):
items = Item.objects.filter(kind__in=collection.included_item_kinds()).select_related('owner', 'team').prefetch_related('tags', 'attachments') items = Item.objects.filter(workspace=collection.workspace, kind__in=collection.included_item_kinds()).select_related('owner', 'team').prefetch_related('tags', 'attachments')
if collection.visibility == Item.Visibility.PUBLIC: if collection.visibility == Item.Visibility.PUBLIC:
items = items.filter(visibility=Item.Visibility.PUBLIC) items = items.filter(visibility=Item.Visibility.PUBLIC)
elif collection.visibility == Item.Visibility.TEAM: elif collection.visibility == Item.Visibility.TEAM:
@@ -870,20 +943,21 @@ def item_can_change_for_collection(user, item, collection):
return True return True
def remove_team_tags(content): def remove_team_tags(content, workspace):
slugs = Team.objects.values_list('slug', flat=True) slugs = Team.objects.filter(workspace=workspace).values_list('slug', flat=True)
for slug in slugs: for slug in slugs:
content = re.sub(rf'(?<!\w)#{re.escape(slug)}\b', '', content, flags=re.I) content = re.sub(rf'(?<!\w)#{re.escape(slug)}\b', '', content, flags=re.I)
return re.sub(r'[ \t]{2,}', ' ', content).strip() return re.sub(r'[ \t]{2,}', ' ', content).strip()
def adapt_item_for_collection(item, collection): def adapt_item_for_collection(item, collection):
item.content = remove_team_tags(item.content) item.workspace = collection.workspace
item.content = remove_team_tags(item.content, collection.workspace)
if collection.team: if collection.team:
item.content = f'{item.content} #{collection.team.slug}'.strip() item.content = f'{item.content} #{collection.team.slug}'.strip()
item.visibility = collection.visibility item.visibility = collection.visibility
item.team = collection.team item.team = collection.team
item.save(update_fields=['content', 'visibility', 'team', 'updated_at']) item.save(update_fields=['workspace', 'content', 'visibility', 'team', 'updated_at'])
item.sync_metadata() item.sync_metadata()
item.team = collection.team item.team = collection.team
item.visibility = collection.visibility item.visibility = collection.visibility
@@ -893,7 +967,7 @@ def adapt_item_for_collection(item, collection):
def copy_item_for_collection(item, collection, owner): def copy_item_for_collection(item, collection, owner):
copied = Item.objects.create( copied = Item.objects.create(
owner=owner, team=collection.team, kind=Item.Kind.NOTE, content=item.content, workspace=collection.workspace, owner=owner, team=collection.team, kind=Item.Kind.NOTE, content=item.content,
comment=item.comment, visibility=collection.visibility, url=item.url, comment=item.comment, visibility=collection.visibility, url=item.url,
) )
for attachment in item.attachments.all(): for attachment in item.attachments.all():
@@ -920,10 +994,11 @@ def add_collection_subcollection(collection, sub_collection):
) )
def editable_collection_notes(user): def editable_collection_notes(user, workspace=None):
workspace = current_workspace(user, workspace=workspace)
return Item.objects.select_related('owner', 'team').prefetch_related('tags', 'attachments').filter( return Item.objects.select_related('owner', 'team').prefetch_related('tags', 'attachments').filter(
kind=Item.Kind.NOTE, workspace=workspace, kind=Item.Kind.NOTE,
).filter(Q(owner=user) | Q(team_id__in=user_team_ids(user))).distinct() ).filter(Q(owner=user) | Q(team_id__in=user_team_ids(user, workspace))).distinct()
def collection_is_structured_mode(mode): def collection_is_structured_mode(mode):
@@ -938,8 +1013,8 @@ def collection_detail_context(request, collection, pending_item=None):
if can_edit: if can_edit:
used_item_ids = set(collection.sections.filter(item__isnull=False).values_list('item_id', flat=True)) used_item_ids = set(collection.sections.filter(item__isnull=False).values_list('item_id', flat=True))
used_collection_ids = set(collection.sections.filter(sub_collection__isnull=False).values_list('sub_collection_id', flat=True)) used_collection_ids = set(collection.sections.filter(sub_collection__isnull=False).values_list('sub_collection_id', flat=True))
candidates = editable_collection_notes(request.user).annotate(collection_use_count=Count('collection_sections', distinct=True)) candidates = editable_collection_notes(request.user, workspace=collection.workspace).annotate(collection_use_count=Count('collection_sections', distinct=True))
collection_candidates = visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES).exclude(pk=collection.pk).annotate(collection_use_count=Count('containing_sections', distinct=True)) collection_candidates = visible_collections(request.user, workspace=active_workspace(request)).filter(mode__in=Collection.STRUCTURED_MODES).exclude(pk=collection.pk).annotate(collection_use_count=Count('containing_sections', distinct=True))
collection_candidates = [candidate for candidate in collection_candidates if collection_subcollection_is_compatible(collection, candidate) and not collection_contains_collection(candidate, collection)] collection_candidates = [candidate for candidate in collection_candidates if collection_subcollection_is_compatible(collection, candidate) and not collection_contains_collection(candidate, collection)]
if q: if q:
candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct() candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
@@ -966,7 +1041,7 @@ def collection_detail_context(request, collection, pending_item=None):
@login_required @login_required
def collection_list(request): def collection_list(request):
active_mode = request.GET.get('mode', 'listed') active_mode = request.GET.get('mode', 'listed')
collections = visible_collections(request.user).prefetch_related('filter_tags') collections = visible_collections(request.user, workspace=active_workspace(request)).prefetch_related('filter_tags')
if active_mode == 'manual': if active_mode == 'manual':
collections = collections.filter(mode=Collection.Mode.MANUAL) collections = collections.filter(mode=Collection.Mode.MANUAL)
elif active_mode == 'tutorial': elif active_mode == 'tutorial':
@@ -982,20 +1057,21 @@ def collection_list(request):
for collection in collections: for collection in collections:
count = collection_tag_items(collection).count() if collection.mode == Collection.Mode.TAGS else collection.sections.count() count = collection_tag_items(collection).count() if collection.mode == Collection.Mode.TAGS else collection.sections.count()
entries.append({'collection': collection, 'section_count': count}) entries.append({'collection': collection, 'section_count': count})
orphan_chapters = visible_collections(request.user).filter(mode=Collection.Mode.CHAPTER, containing_sections__isnull=True) if active_mode != 'chapter' else Collection.objects.none() orphan_chapters = visible_collections(request.user, workspace=active_workspace(request)).filter(mode=Collection.Mode.CHAPTER, containing_sections__isnull=True) if active_mode != 'chapter' else Collection.objects.none()
return render(request, 'core/collection_list.html', {'entries': entries, 'active_mode': active_mode, 'orphan_chapters': orphan_chapters}) return render(request, 'core/collection_list.html', {'entries': entries, 'active_mode': active_mode, 'orphan_chapters': orphan_chapters})
@login_required @login_required
def collection_create(request): def collection_create(request):
if request.method == 'POST': if request.method == 'POST':
form = CollectionForm(request.POST, user=request.user) form = CollectionForm(request.POST, user=request.user, workspace=active_workspace(request))
if form.is_valid(): if form.is_valid():
visibility, team = collection_target_from_form(form, request.user) visibility, team = collection_target_from_form(form, request.user)
if visibility == Item.Visibility.TEAM and not team: if visibility == Item.Visibility.TEAM and not team:
form.add_error('team', _('Select a team for a team collection.')) form.add_error('team', _('Select a team for a team collection.'))
else: else:
collection = form.save(commit=False) collection = form.save(commit=False)
collection.workspace = active_workspace(request)
collection.owner = request.user collection.owner = request.user
collection.visibility = visibility collection.visibility = visibility
collection.team = team collection.team = team
@@ -1006,13 +1082,13 @@ def collection_create(request):
messages.success(request, _('Collection created.')) messages.success(request, _('Collection created.'))
return redirect(collection) return redirect(collection)
else: else:
form = CollectionForm(user=request.user) form = CollectionForm(user=request.user, workspace=active_workspace(request))
return render(request, 'core/collection_form.html', {'form': form, 'heading': _('Create collection')}) return render(request, 'core/collection_form.html', {'form': form, 'heading': _('Create collection')})
@login_required @login_required
def collection_edit(request, pk): def collection_edit(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk) collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)), pk=pk)
if not user_can_edit_collection(request.user, collection): if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
if request.method == 'POST': if request.method == 'POST':
@@ -1053,7 +1129,7 @@ def collection_edit(request, pk):
def collection_detail(request, pk): def collection_detail(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk) collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)), pk=pk)
tag_items = list(collection_tag_items(collection)) if collection.mode == Collection.Mode.TAGS else [] tag_items = list(collection_tag_items(collection)) if collection.mode == Collection.Mode.TAGS else []
for item in tag_items: for item in tag_items:
item.can_toggle_done = item.kind == Item.Kind.TODO and user_can_edit_item(request.user, item) item.can_toggle_done = item.kind == Item.Kind.TODO and user_can_edit_item(request.user, item)
@@ -1067,7 +1143,7 @@ def collection_detail(request, pk):
@login_required @login_required
def collection_manage(request, pk): def collection_manage(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk) collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)), pk=pk)
if not user_can_edit_collection(request.user, collection): if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
if collection.mode == Collection.Mode.TAGS: if collection.mode == Collection.Mode.TAGS:
@@ -1078,7 +1154,7 @@ def collection_manage(request, pk):
if request.method == 'POST': if request.method == 'POST':
action = request.POST.get('action') action = request.POST.get('action')
if action in {'add', 'confirm_add'}: if action in {'add', 'confirm_add'}:
item = get_object_or_404(editable_collection_notes(request.user), pk=request.POST.get('item_id')) item = get_object_or_404(editable_collection_notes(request.user, workspace=collection.workspace), pk=request.POST.get('item_id'))
if collection_item_is_compatible(collection, item): if collection_item_is_compatible(collection, item):
add_collection_section(collection, item) add_collection_section(collection, item)
return redirect(redirect_url) return redirect(redirect_url)
@@ -1100,7 +1176,7 @@ def collection_manage(request, pk):
title = request.POST.get('title', '').strip()[:200] title = request.POST.get('title', '').strip()[:200]
if title: if title:
chapter = Collection.objects.create( chapter = Collection.objects.create(
owner=request.user, title=title, mode=Collection.Mode.CHAPTER, workspace=collection.workspace, owner=request.user, title=title, mode=Collection.Mode.CHAPTER,
visibility=collection.visibility, team=collection.team, visibility=collection.visibility, team=collection.team,
) )
add_collection_subcollection(collection, chapter) add_collection_subcollection(collection, chapter)
@@ -1108,7 +1184,7 @@ def collection_manage(request, pk):
messages.error(request, _('Enter a chapter title.')) messages.error(request, _('Enter a chapter title.'))
return redirect(redirect_url) return redirect(redirect_url)
if action == 'add_collection': if action == 'add_collection':
sub_collection = get_object_or_404(visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES), pk=request.POST.get('collection_id')) sub_collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)).filter(mode__in=Collection.STRUCTURED_MODES), pk=request.POST.get('collection_id'))
if sub_collection.pk == collection.pk or collection_contains_collection(sub_collection, collection): if sub_collection.pk == collection.pk or collection_contains_collection(sub_collection, collection):
messages.error(request, _('This would create a collection loop.')) messages.error(request, _('This would create a collection loop.'))
elif not collection_subcollection_is_compatible(collection, sub_collection): elif not collection_subcollection_is_compatible(collection, sub_collection):
@@ -1154,7 +1230,7 @@ def collection_manage(request, pk):
@login_required @login_required
@require_POST @require_POST
def collection_delete(request, pk): def collection_delete(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk) collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)), pk=pk)
if not user_can_delete_collection(request.user, collection): if not user_can_delete_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
collection.delete() collection.delete()
@@ -1164,14 +1240,14 @@ def collection_delete(request, pk):
@login_required @login_required
def item_detail(request, pk): def item_detail(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk) item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk)
return render(request, 'core/detail.html', {'item': item}) return render(request, 'core/detail.html', {'item': item})
@login_required @login_required
@require_POST @require_POST
def toggle_done(request, pk): def toggle_done(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk, kind=Item.Kind.TODO) item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk, kind=Item.Kind.TODO)
if not user_can_edit_item(request.user, item): if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
item.is_done = not item.is_done item.is_done = not item.is_done
@@ -1187,13 +1263,13 @@ def toggle_done(request, pk):
@login_required @login_required
def item_card(request, pk): def item_card(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk) item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk)
return render(request, 'core/_item.html', {'item': item}) return render(request, 'core/_item.html', {'item': item})
@login_required @login_required
def edit_item(request, pk): def edit_item(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk) item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk)
if not user_can_edit_item(request.user, item): if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
if request.method == 'POST': if request.method == 'POST':
@@ -1206,7 +1282,7 @@ def edit_item(request, pk):
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE) target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team): if not item_scope_fits_collections(item, target_visibility, target_team):
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.')) form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': visible_tags(request.user)}, status=409) return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': visible_tags(request.user, workspace=active_workspace(request))}, status=409)
item.visibility, item.team = target_visibility, target_team item.visibility, item.team = target_visibility, target_team
item.save() item.save()
form.save_m2m() form.save_m2m()
@@ -1217,12 +1293,12 @@ def edit_item(request, pk):
response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4}) response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
response['HX-Trigger'] = 'tagsChanged' response['HX-Trigger'] = 'tagsChanged'
return response return response
return render(request, 'core/_edit_form.html', {'item': item, 'tags': visible_tags(request.user)}) return render(request, 'core/_edit_form.html', {'item': item, 'tags': visible_tags(request.user, workspace=active_workspace(request))})
@login_required @login_required
def item_editor(request, pk): def item_editor(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk) item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk)
if not user_can_edit_item(request.user, item): if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
next_url = request.GET.get('next') or item.get_absolute_url() next_url = request.GET.get('next') or item.get_absolute_url()
@@ -1236,7 +1312,7 @@ def item_editor(request, pk):
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE) target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team): if not item_scope_fits_collections(item, target_visibility, target_team):
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.')) form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': visible_tags(request.user), 'next_url': next_url}, status=409) return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'next_url': next_url}, status=409)
item.visibility, item.team = target_visibility, target_team item.visibility, item.team = target_visibility, target_team
item.save() item.save()
form.save_m2m() form.save_m2m()
@@ -1245,13 +1321,13 @@ def item_editor(request, pk):
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE) apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
return redirect(next_url) return redirect(next_url)
return render(request, 'core/item_editor.html', {'item': item, 'tags': visible_tags(request.user), 'next_url': next_url}) return render(request, 'core/item_editor.html', {'item': item, 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'next_url': next_url})
@login_required @login_required
@require_POST @require_POST
def delete_item(request, pk): def delete_item(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk) item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk)
if not user_can_delete_item(request.user, item): if not user_can_delete_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
try: try:
@@ -1265,24 +1341,25 @@ def delete_item(request, pk):
@login_required @login_required
def kanban_list(request): def kanban_list(request):
if request.method == 'POST': if request.method == 'POST':
form = KanbanForm(request.POST) form = KanbanForm(request.POST, user=request.user, workspace=active_workspace(request))
if form.is_valid(): if form.is_valid():
kanban = form.save(commit=False) kanban = form.save(commit=False)
kanban.owner = request.user kanban.owner = request.user
kanban.save() kanban.save()
return redirect('kanban_detail', pk=kanban.pk) return redirect('kanban_detail', pk=kanban.pk)
kanbans = Kanban.objects.filter(owner=request.user).select_related('tag') workspace = active_workspace(request)
kanbans = Kanban.objects.filter(workspace=workspace, owner=request.user).select_related('tag')
stats = [] stats = []
for kanban in kanbans: for kanban in kanbans:
qs = Item.objects.filter(owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag) qs = Item.objects.filter(workspace=workspace, owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag)
stats.append({'kanban': kanban, 'open': qs.filter(is_done=False).count(), 'done': qs.filter(is_done=True).count()}) stats.append({'kanban': kanban, 'open': qs.filter(is_done=False).count(), 'done': qs.filter(is_done=True).count()})
return render(request, 'core/kanban_list.html', {'form': KanbanForm(), 'stats': stats}) return render(request, 'core/kanban_list.html', {'form': KanbanForm(user=request.user, workspace=active_workspace(request)), 'stats': stats})
@login_required @login_required
def kanban_detail(request, pk): def kanban_detail(request, pk):
kanban = get_object_or_404(Kanban.objects.select_related('tag'), pk=pk, owner=request.user) kanban = get_object_or_404(Kanban.objects.select_related('tag'), pk=pk, owner=request.user)
todos = Item.objects.filter(owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag).prefetch_related('tags') todos = Item.objects.filter(workspace=kanban.workspace, owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag).prefetch_related('tags')
return render(request, 'core/kanban_detail.html', { return render(request, 'core/kanban_detail.html', {
'kanban': kanban, 'kanban': kanban,
'inbox_items': todos.filter(is_done=False, kanban_status='inbox'), 'inbox_items': todos.filter(is_done=False, kanban_status='inbox'),
@@ -1296,7 +1373,7 @@ def kanban_detail(request, pk):
@require_POST @require_POST
def kanban_move(request, pk, item_pk): def kanban_move(request, pk, item_pk):
kanban = get_object_or_404(Kanban, pk=pk, owner=request.user) kanban = get_object_or_404(Kanban, pk=pk, owner=request.user)
item = get_object_or_404(Item, pk=item_pk, owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag) item = get_object_or_404(Item, workspace=kanban.workspace, pk=item_pk, owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag)
status = request.POST.get('status') status = request.POST.get('status')
if status not in {'inbox', 'todo', 'progress', 'done'}: if status not in {'inbox', 'todo', 'progress', 'done'}:
return JsonResponse({'error': 'invalid status'}, status=400) return JsonResponse({'error': 'invalid status'}, status=400)
@@ -1312,7 +1389,7 @@ def tag_list(request):
prefs, created = UserPreference.objects.get_or_create(user=request.user) prefs, created = UserPreference.objects.get_or_create(user=request.user)
active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()] active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()]
active_tag_mode = 'all' if request.GET.get('tag_mode') == 'all' else 'any' active_tag_mode = 'all' if request.GET.get('tag_mode') == 'all' else 'any'
return render(request, 'core/_tags.html', {'tags': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode}) return render(request, 'core/_tags.html', {'tags': index_filter_tags(request, request.user, prefs, active_tags, workspace=active_workspace(request)), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode})
def requested_default_item_kind(post, prefs, save=False): def requested_default_item_kind(post, prefs, save=False):
@@ -1334,12 +1411,12 @@ def set_default_item_kind(request):
@login_required @login_required
def api_tags(request): def api_tags(request):
return JsonResponse({'tags': list(visible_tags(request.user).values_list('name', flat=True))}) return JsonResponse({'tags': list(visible_tags(request.user, workspace=active_workspace(request)).values_list('name', flat=True))})
@login_required @login_required
def api_attachment_suggestions(request): def api_attachment_suggestions(request):
item = get_object_or_404(visible_items(request.user), pk=request.GET.get('item')) item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=request.GET.get('item'))
if not user_can_edit_item(request.user, item): if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403) return JsonResponse({'error': 'forbidden'}, status=403)
data = [{'id': a.id, 'name': a.file.name.split('/')[-1], 'url': a.file.url} for a in item.attachments.all()] data = [{'id': a.id, 'name': a.file.name.split('/')[-1], 'url': a.file.url} for a in item.attachments.all()]
@@ -1349,7 +1426,7 @@ def api_attachment_suggestions(request):
@login_required @login_required
def api_item_suggestions(request): def api_item_suggestions(request):
q = request.GET.get('q', '').strip() q = request.GET.get('q', '').strip()
items = visible_items(request.user) items = visible_items(request.user, workspace=active_workspace(request))
if q: if q:
items = items.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct() items = items.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
data = [{'id': i.id, 'kind': i.kind, 'label': i.content[:80]} for i in items[:10]] data = [{'id': i.id, 'kind': i.kind, 'label': i.content[:80]} for i in items[:10]]
@@ -1413,7 +1490,7 @@ def api_items(request):
prefs, created = UserPreference.objects.get_or_create(user=user) prefs, created = UserPreference.objects.get_or_create(user=user)
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind) kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
due_at = data['due_at'] if 'due_at' in data else command_due_at due_at = data['due_at'] if 'due_at' in data else command_due_at
item = Item.objects.create(owner=user, kind=data.get('kind') or kind, visibility=data.get('visibility') or visibility, content=content, url=data.get('url') or url, due_at=due_at) item = Item.objects.create(workspace=current_workspace(user, request.api_key), owner=user, kind=data.get('kind') or kind, visibility=data.get('visibility') or visibility, content=content, url=data.get('url') or url, due_at=due_at)
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I))) apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I)))
return JsonResponse(serialize_item(item), status=201) return JsonResponse(serialize_item(item), status=201)
+1
View File
@@ -43,6 +43,7 @@ TEMPLATES = [{
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'core.context_processors.overview_navigation', 'core.context_processors.overview_navigation',
'core.context_processors.active_workspace_navigation',
]}, ]},
}] }]
WSGI_APPLICATION = 'my2dos.wsgi.application' WSGI_APPLICATION = 'my2dos.wsgi.application'