Introduce workspace tenancy boundaries
This commit is contained in:
+3
-1
@@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, Workspace, WorkspaceMembership
|
||||
|
||||
|
||||
class CollectionSectionInline(admin.TabularInline):
|
||||
@@ -42,3 +42,5 @@ admin.site.register(TeamMembership)
|
||||
admin.site.register(TeamInvite)
|
||||
admin.site.register(Kanban)
|
||||
admin.site.register(UserPreference)
|
||||
admin.site.register(Workspace)
|
||||
admin.site.register(WorkspaceMembership)
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from .models import UserPreference
|
||||
from .models import UserPreference, WorkspaceMembership, get_or_create_personal_workspace
|
||||
|
||||
|
||||
def active_workspace_navigation(request):
|
||||
if not request.user.is_authenticated:
|
||||
return {'active_workspace': None, 'workspace_memberships': []}
|
||||
workspace = get_or_create_personal_workspace(request.user)
|
||||
workspace_id = request.session.get('workspace_id')
|
||||
if workspace_id:
|
||||
membership = WorkspaceMembership.objects.select_related('workspace').filter(user=request.user, workspace_id=workspace_id).first()
|
||||
if membership:
|
||||
workspace = membership.workspace
|
||||
return {
|
||||
'active_workspace': workspace,
|
||||
'workspace_memberships': WorkspaceMembership.objects.filter(user=request.user).select_related('workspace').order_by('workspace__name'),
|
||||
}
|
||||
|
||||
|
||||
def overview_navigation(request):
|
||||
|
||||
+46
-11
@@ -3,7 +3,7 @@ from django.contrib.auth.forms import UserCreationForm
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference
|
||||
from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference, Workspace, get_or_create_personal_workspace
|
||||
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
@@ -73,6 +73,20 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class WorkspaceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Workspace
|
||||
fields = ['name', 'slug']
|
||||
labels = {'name': _('Workspace name'), 'slug': _('Short name')}
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Company or workspace name')}),
|
||||
'slug': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'firma'}),
|
||||
}
|
||||
|
||||
def clean_slug(self):
|
||||
return self.cleaned_data['slug'].strip().lower()
|
||||
|
||||
|
||||
class UserInviteForm(forms.ModelForm):
|
||||
email = forms.EmailField(required=True, label=_('Email'), widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'person@example.com', 'required': True}))
|
||||
|
||||
@@ -96,6 +110,11 @@ class InviteRegistrationForm(UserCreationForm):
|
||||
|
||||
|
||||
class TeamForm(forms.ModelForm):
|
||||
def __init__(self, *args, user=None, workspace=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
self.workspace = workspace or (get_or_create_personal_workspace(user) if user and user.is_authenticated else None)
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = ['name', 'slug']
|
||||
@@ -106,7 +125,15 @@ class TeamForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
def clean_slug(self):
|
||||
return self.cleaned_data['slug'].strip().lstrip('#').lower()
|
||||
slug = self.cleaned_data['slug'].strip().lstrip('#').lower()
|
||||
if self.workspace and Team.objects.filter(workspace=self.workspace, slug=slug).exclude(pk=self.instance.pk).exists():
|
||||
raise forms.ValidationError(_('This team tag is already used in this workspace.'))
|
||||
return slug
|
||||
|
||||
def save(self, commit=True):
|
||||
if self.workspace:
|
||||
self.instance.workspace = self.workspace
|
||||
return super().save(commit=commit)
|
||||
|
||||
|
||||
class TeamInviteForm(forms.ModelForm):
|
||||
@@ -120,6 +147,11 @@ class TeamInviteForm(forms.ModelForm):
|
||||
class KanbanForm(forms.ModelForm):
|
||||
tag_name = forms.CharField(label=_('Tag'), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'}))
|
||||
|
||||
def __init__(self, *args, user=None, workspace=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
self.workspace = workspace or (get_or_create_personal_workspace(user) if user and user.is_authenticated else None)
|
||||
|
||||
class Meta:
|
||||
model = Kanban
|
||||
fields = ['name', 'tag_name']
|
||||
@@ -127,7 +159,8 @@ class KanbanForm(forms.ModelForm):
|
||||
|
||||
def save(self, commit=True):
|
||||
tag_name = self.cleaned_data['tag_name'].strip().lstrip('#').lower()
|
||||
tag, _ = Tag.objects.get_or_create(name=tag_name)
|
||||
tag, _ = Tag.objects.get_or_create(workspace=self.workspace, name=tag_name)
|
||||
self.instance.workspace = self.workspace
|
||||
self.instance.tag = tag
|
||||
return super().save(commit=commit)
|
||||
|
||||
@@ -135,19 +168,20 @@ class KanbanForm(forms.ModelForm):
|
||||
class CollectionForm(forms.ModelForm):
|
||||
team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, label=_('Team'), widget=forms.Select(attrs={'class': 'form-select'}))
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, user=None, workspace=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['visibility'].choices = [
|
||||
(Item.Visibility.PRIVATE, _('Private')),
|
||||
(Item.Visibility.PUBLIC, _('Public')),
|
||||
]
|
||||
if user and user.is_authenticated:
|
||||
team_ids = user.team_memberships.values_list('team_id', flat=True)
|
||||
self.fields['team'].queryset = Team.objects.filter(pk__in=team_ids).order_by('name')
|
||||
workspace = self.instance.workspace if self.instance and self.instance.pk else (workspace or get_or_create_personal_workspace(user))
|
||||
team_ids = user.team_memberships.filter(team__workspace=workspace).values_list('team_id', flat=True)
|
||||
self.fields['team'].queryset = Team.objects.filter(workspace=workspace, pk__in=team_ids).order_by('name')
|
||||
self.fields['team'].initial = self.instance.team_id if self.instance and self.instance.pk else None
|
||||
item_scope = Q(items__owner=user) | Q(items__team_id__in=team_ids)
|
||||
team_slugs = Team.objects.values_list('slug', flat=True)
|
||||
self.fields['filter_tags'].queryset = Tag.objects.filter(item_scope).exclude(name__in=team_slugs).distinct().order_by('name')
|
||||
item_scope = Q(items__workspace=workspace) & (Q(items__owner=user) | Q(items__team_id__in=team_ids))
|
||||
team_slugs = Team.objects.filter(workspace=workspace).values_list('slug', flat=True)
|
||||
self.fields['filter_tags'].queryset = Tag.objects.filter(workspace=workspace).filter(item_scope).exclude(name__in=team_slugs).distinct().order_by('name')
|
||||
else:
|
||||
self.fields['team'].queryset = Team.objects.none()
|
||||
self.fields['filter_tags'].queryset = Tag.objects.none()
|
||||
@@ -189,10 +223,11 @@ class CollectionForm(forms.ModelForm):
|
||||
|
||||
|
||||
class ApiKeyForm(forms.ModelForm):
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, user=None, workspace=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if user and user.is_authenticated:
|
||||
self.fields['tags'].queryset = Tag.objects.filter(items__owner=user).distinct().order_by('name')
|
||||
workspace = workspace or get_or_create_personal_workspace(user)
|
||||
self.fields['tags'].queryset = Tag.objects.filter(workspace=workspace, items__owner=user).distinct().order_by('name')
|
||||
else:
|
||||
self.fields['tags'].queryset = Tag.objects.none()
|
||||
|
||||
|
||||
@@ -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
@@ -35,17 +35,63 @@ def without_markdown_code(text):
|
||||
def delete_unused_tags(tags=None):
|
||||
"""Delete tags that are no longer used by items or tag-based features."""
|
||||
candidates = Tag.objects.all() if tags is None else Tag.objects.filter(pk__in=[tag.pk for tag in tags])
|
||||
candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True).exclude(
|
||||
name__in=Team.objects.values_list('slug', flat=True),
|
||||
).delete()
|
||||
for tag in candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True):
|
||||
if not Team.objects.filter(workspace=tag.workspace, slug=tag.name).exists():
|
||||
tag.delete()
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.SlugField(max_length=80, unique=True, allow_unicode=True)
|
||||
class Workspace(models.Model):
|
||||
name = models.CharField(max_length=120)
|
||||
slug = models.SlugField(max_length=80, unique=True, allow_unicode=True)
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_workspaces')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class WorkspaceMembership(models.Model):
|
||||
class Role(models.TextChoices):
|
||||
OWNER = 'owner', 'Owner'
|
||||
ADMIN = 'admin', 'Admin'
|
||||
MEMBER = 'member', _('Member')
|
||||
|
||||
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='memberships')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='workspace_memberships')
|
||||
role = models.CharField(max_length=10, choices=Role.choices, default=Role.MEMBER)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [('workspace', 'user')]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user} in {self.workspace}'
|
||||
|
||||
|
||||
def personal_workspace_slug(user):
|
||||
return f'user-{user.pk}'
|
||||
|
||||
|
||||
def get_or_create_personal_workspace(user):
|
||||
workspace, _created = Workspace.objects.get_or_create(
|
||||
slug=personal_workspace_slug(user),
|
||||
defaults={'owner': user, 'name': user.get_full_name() or user.get_username() or f'User {user.pk}'},
|
||||
)
|
||||
WorkspaceMembership.objects.get_or_create(workspace=workspace, user=user, defaults={'role': WorkspaceMembership.Role.OWNER})
|
||||
return workspace
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='tags')
|
||||
name = models.SlugField(max_length=80, allow_unicode=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
constraints = [models.UniqueConstraint(fields=['workspace', 'name'], name='unique_workspace_tag_name')]
|
||||
|
||||
def __str__(self):
|
||||
return f'#{self.name}'
|
||||
|
||||
@@ -70,13 +116,20 @@ class UserInvite(models.Model):
|
||||
|
||||
|
||||
class Team(models.Model):
|
||||
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='teams')
|
||||
name = models.CharField(max_length=120)
|
||||
slug = models.SlugField(max_length=80, unique=True, allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos')
|
||||
slug = models.SlugField(max_length=80, allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos')
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_teams')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
constraints = [models.UniqueConstraint(fields=['workspace', 'slug'], name='unique_workspace_team_slug')]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.workspace_id and self.owner_id:
|
||||
self.workspace = get_or_create_personal_workspace(self.owner)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -132,6 +185,7 @@ class Item(models.Model):
|
||||
TEAM = 'team', _('Team')
|
||||
PUBLIC = 'public', _('Public')
|
||||
|
||||
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='items')
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='items')
|
||||
team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='items')
|
||||
kind = models.CharField(max_length=10, choices=Kind.choices, default=Kind.NOTE)
|
||||
@@ -170,18 +224,26 @@ class Item(models.Model):
|
||||
def is_due_today(self):
|
||||
return self.kind == self.Kind.TODO and not self.is_done and self.due_at and timezone.localdate(self.due_at) == timezone.localdate()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.workspace_id and self.owner_id:
|
||||
self.workspace = get_or_create_personal_workspace(self.owner)
|
||||
if self.team_id and self.team.workspace_id != self.workspace_id:
|
||||
self.workspace = self.team.workspace
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def sync_metadata(self):
|
||||
old_tags = list(self.tags.all())
|
||||
metadata_text = without_markdown_code(self.content)
|
||||
tag_names = {m.group(1).lower() for m in TAG_RE.finditer(metadata_text)}
|
||||
tags = [Tag.objects.get_or_create(name=name)[0] for name in tag_names]
|
||||
tags = [Tag.objects.get_or_create(workspace=self.workspace, name=name)[0] for name in tag_names]
|
||||
self.tags.set(tags)
|
||||
delete_unused_tags(old_tags)
|
||||
ids = [int(m.group(2)) for m in LINK_RE.finditer(f'{self.content}\n{self.comment}')]
|
||||
self.linked_items.set(Item.objects.filter(id__in=ids).exclude(id=self.id))
|
||||
self.linked_items.set(Item.objects.filter(workspace=self.workspace, id__in=ids).exclude(id=self.id))
|
||||
|
||||
|
||||
class Kanban(models.Model):
|
||||
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='kanbans')
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='kanbans')
|
||||
name = models.CharField(max_length=120)
|
||||
tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name='kanbans')
|
||||
@@ -191,6 +253,11 @@ class Kanban(models.Model):
|
||||
ordering = ['name']
|
||||
unique_together = [('owner', 'tag')]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.workspace_id and self.owner_id:
|
||||
self.workspace = get_or_create_personal_workspace(self.owner)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -208,6 +275,7 @@ class Collection(models.Model):
|
||||
def is_structured(self):
|
||||
return self.mode in self.STRUCTURED_MODES
|
||||
|
||||
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='collections')
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='collections')
|
||||
team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='collections')
|
||||
title = models.CharField(max_length=200)
|
||||
@@ -225,6 +293,13 @@ class Collection(models.Model):
|
||||
class Meta:
|
||||
ordering = ['-updated_at']
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.workspace_id and self.owner_id:
|
||||
self.workspace = get_or_create_personal_workspace(self.owner)
|
||||
if self.team_id and self.team.workspace_id != self.workspace_id:
|
||||
self.workspace = self.team.workspace
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@@ -285,6 +360,7 @@ class UserPreference(models.Model):
|
||||
|
||||
|
||||
class ApiKey(models.Model):
|
||||
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='api_keys')
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='api_keys')
|
||||
name = models.CharField(max_length=120, default='API Key')
|
||||
token = models.CharField(max_length=80, unique=True, editable=False)
|
||||
@@ -296,6 +372,8 @@ class ApiKey(models.Model):
|
||||
ordering = ['-created_at']
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.workspace_id and self.owner_id:
|
||||
self.workspace = get_or_create_personal_workspace(self.owner)
|
||||
if not self.token:
|
||||
self.token = secrets.token_urlsafe(32)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -30,7 +30,12 @@
|
||||
</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 %}
|
||||
<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 %}
|
||||
{% else %}<a class="btn btn-sm btn-dark ms-auto" href="{% url 'login' %}">{% trans "Login" %}</a>{% endif %}
|
||||
</div></div></nav>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<nav class="nav nav-pills flex-column gap-1">
|
||||
<a class="nav-link" href="#profile">{% trans "Profile" %}</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="#api">{% trans "API keys" %}</a>
|
||||
</nav>
|
||||
@@ -58,6 +59,21 @@
|
||||
</form>
|
||||
</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">
|
||||
<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>
|
||||
@@ -73,7 +89,7 @@
|
||||
|
||||
<div id="api" class="card item-card shadow-sm"><div class="card-body">
|
||||
<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">
|
||||
{% csrf_token %}<input type="hidden" name="action" value="apikey">
|
||||
<div><label class="form-label">{% trans "Name" %}</label>{{ key_form.name }}</div>
|
||||
|
||||
+87
-10
@@ -9,7 +9,7 @@ from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference, Workspace, WorkspaceMembership, get_or_create_personal_workspace
|
||||
from .templatetags.markdown_extras import markdown, markdown_title
|
||||
from .views import parse_due_command, parse_quick_content
|
||||
|
||||
@@ -40,6 +40,83 @@ class ItemMetadataTests(TestCase):
|
||||
self.assertFalse(Tag.objects.filter(name='new').exists())
|
||||
|
||||
|
||||
class WorkspaceTenancyTests(TestCase):
|
||||
def test_same_tag_name_is_scoped_per_personal_workspace(self):
|
||||
first_user = get_user_model().objects.create_user(username='tenant-a', password='secret')
|
||||
second_user = get_user_model().objects.create_user(username='tenant-b', password='secret')
|
||||
first = Item.objects.create(owner=first_user, content='One #shared')
|
||||
first.sync_metadata()
|
||||
second = Item.objects.create(owner=second_user, content='Two #shared')
|
||||
second.sync_metadata()
|
||||
|
||||
self.assertEqual(Tag.objects.filter(name='shared').count(), 2)
|
||||
self.assertNotEqual(first.tags.get(name='shared').workspace_id, second.tags.get(name='shared').workspace_id)
|
||||
|
||||
def test_same_team_tag_is_allowed_in_different_workspaces(self):
|
||||
first_user = get_user_model().objects.create_user(username='team-a', password='secret')
|
||||
second_user = get_user_model().objects.create_user(username='team-b', password='secret')
|
||||
|
||||
first = Team.objects.create(owner=first_user, name='Docs A', slug='docs')
|
||||
second = Team.objects.create(owner=second_user, name='Docs B', slug='docs')
|
||||
|
||||
self.assertNotEqual(first.workspace_id, second.workspace_id)
|
||||
|
||||
def test_workspace_can_be_created_and_becomes_active(self):
|
||||
user = get_user_model().objects.create_user(username='workspace-create', password='secret')
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.post(reverse('settings'), {'action': 'workspace', 'name': 'Acme Inc', 'slug': 'acme'})
|
||||
|
||||
workspace = Workspace.objects.get(slug='acme')
|
||||
self.assertRedirects(response, reverse('settings'))
|
||||
self.assertEqual(self.client.session['workspace_id'], workspace.id)
|
||||
self.assertTrue(WorkspaceMembership.objects.filter(workspace=workspace, user=user, role=WorkspaceMembership.Role.OWNER).exists())
|
||||
|
||||
def test_workspace_switch_changes_overview_scope(self):
|
||||
user = get_user_model().objects.create_user(username='workspace-switch', password='secret')
|
||||
personal = get_or_create_personal_workspace(user)
|
||||
company = Workspace.objects.create(owner=user, name='Company', slug='company')
|
||||
WorkspaceMembership.objects.create(workspace=company, user=user, role=WorkspaceMembership.Role.OWNER)
|
||||
Item.objects.create(workspace=personal, owner=user, content='Personal item')
|
||||
Item.objects.create(workspace=company, owner=user, content='Company item')
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.post(reverse('workspace_switch', args=[company.id]), {'next': reverse('index')})
|
||||
self.assertRedirects(response, reverse('index'))
|
||||
response = self.client.get(reverse('index'))
|
||||
|
||||
self.assertContains(response, 'Company item')
|
||||
self.assertNotContains(response, 'Personal item')
|
||||
|
||||
def test_workspace_public_item_is_visible_to_workspace_member_only(self):
|
||||
owner = get_user_model().objects.create_user(username='workspace-owner', password='secret')
|
||||
member = get_user_model().objects.create_user(username='workspace-member', password='secret')
|
||||
outsider = get_user_model().objects.create_user(username='workspace-outsider', password='secret')
|
||||
workspace = Workspace.objects.create(owner=owner, name='Company', slug='company-public')
|
||||
WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER)
|
||||
WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER)
|
||||
Item.objects.create(workspace=workspace, owner=owner, content='Workspace public', visibility=Item.Visibility.PUBLIC)
|
||||
|
||||
self.client.force_login(member)
|
||||
self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')})
|
||||
self.assertContains(self.client.get(reverse('index')), 'Workspace public')
|
||||
|
||||
self.client.force_login(outsider)
|
||||
self.assertNotContains(self.client.get(reverse('index')), 'Workspace public')
|
||||
|
||||
def test_authenticated_overview_is_limited_to_current_workspace(self):
|
||||
user = get_user_model().objects.create_user(username='tenant-visible', password='secret')
|
||||
other = get_user_model().objects.create_user(username='tenant-other', password='secret')
|
||||
own = Item.objects.create(owner=user, content='Own item')
|
||||
public_other = Item.objects.create(owner=other, content='Public other item', visibility=Item.Visibility.PUBLIC)
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.get(reverse('index'))
|
||||
|
||||
self.assertContains(response, 'Own item')
|
||||
self.assertNotContains(response, 'Public other item')
|
||||
|
||||
|
||||
class DefaultItemKindTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(username='tester', password='secret')
|
||||
@@ -262,7 +339,7 @@ class NavigationAndLayoutTests(TestCase):
|
||||
self.assertNotIn('#noteonly', filter_tags)
|
||||
self.assertIn("'openonly'", html)
|
||||
self.assertIn("'noteonly'", html)
|
||||
self.assertIn("'publiconly'", html)
|
||||
self.assertNotIn('publiconly', html)
|
||||
self.assertNotIn('foreignonly', html)
|
||||
|
||||
response = self.client.get(f'{reverse("tag_list")}?status=archive')
|
||||
@@ -283,7 +360,7 @@ class NavigationAndLayoutTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
tags = response.json()['tags']
|
||||
self.assertIn('ownapi', tags)
|
||||
self.assertIn('publicapi', tags)
|
||||
self.assertNotIn('publicapi', tags)
|
||||
self.assertNotIn('foreignapi', tags)
|
||||
|
||||
def test_due_filter_also_shows_next_seven_days_separately(self):
|
||||
@@ -482,7 +559,7 @@ class CollectionTests(TestCase):
|
||||
def test_collection_form_separates_teams_and_content_tags(self):
|
||||
team = Team.objects.create(name='Docs team', slug='docs', owner=self.user)
|
||||
TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER)
|
||||
Tag.objects.create(name='docs')
|
||||
Tag.objects.create(workspace=get_or_create_personal_workspace(self.user), name='docs')
|
||||
note = Item.objects.create(owner=self.user, content='#ordinary')
|
||||
note.sync_metadata()
|
||||
response = self.client.get(reverse('collection_create'))
|
||||
@@ -801,15 +878,14 @@ class CollectionTests(TestCase):
|
||||
self.assertRedirects(response, manage_url)
|
||||
self.assertTrue(parent.sections.filter(sub_collection=child).exists())
|
||||
|
||||
def test_public_collection_is_visible_without_login(self):
|
||||
def test_public_collection_is_workspace_public_not_anonymous(self):
|
||||
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
|
||||
self.client.logout()
|
||||
response = self.client.get(collection.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotContains(response, reverse('collection_manage', args=[collection.id]))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_collection_tag_is_not_removed_as_unused(self):
|
||||
tag = Tag.objects.create(name='documentation')
|
||||
tag = Tag.objects.create(workspace=get_or_create_personal_workspace(self.user), name='documentation')
|
||||
collection = Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS)
|
||||
collection.filter_tags.add(tag)
|
||||
note = Item.objects.create(owner=self.user, content='#temporary')
|
||||
@@ -835,8 +911,9 @@ class CollectionTests(TestCase):
|
||||
class ApiItemFilterTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(username='api-filter', password='secret')
|
||||
self.help_tag = Tag.objects.create(name='help')
|
||||
self.api_key = ApiKey.objects.create(owner=self.user, name='Help CLI')
|
||||
self.workspace = get_or_create_personal_workspace(self.user)
|
||||
self.help_tag = Tag.objects.create(workspace=self.workspace, name='help')
|
||||
self.api_key = ApiKey.objects.create(workspace=self.workspace, owner=self.user, name='Help CLI')
|
||||
self.api_key.tags.add(self.help_tag)
|
||||
|
||||
def create_item(self, content):
|
||||
|
||||
@@ -17,6 +17,7 @@ urlpatterns = [
|
||||
path('default-item-kind/', views.set_default_item_kind, name='set_default_item_kind'),
|
||||
path('settings/', views.settings_view, name='settings'),
|
||||
path('settings/api-keys/<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('teams/', views.team_list, name='team_list'),
|
||||
path('teams/<int:pk>/', views.team_detail, name='team_detail'),
|
||||
|
||||
+162
-85
@@ -20,8 +20,8 @@ from django.utils.translation import gettext as _
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, TAG_RE, without_markdown_code
|
||||
from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm, WorkspaceForm
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, Workspace, WorkspaceMembership, TAG_RE, get_or_create_personal_workspace, without_markdown_code
|
||||
from .templatetags.markdown_extras import strip_tags_outside_code
|
||||
|
||||
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
|
||||
@@ -188,14 +188,51 @@ def attach_files_and_replace_tokens(item, files):
|
||||
item.save(update_fields=['content', 'comment', 'updated_at'])
|
||||
|
||||
|
||||
def user_team_ids(user):
|
||||
def current_workspace(user, api_key=None, workspace=None):
|
||||
if api_key:
|
||||
return api_key.workspace
|
||||
if workspace:
|
||||
return workspace
|
||||
if user.is_authenticated:
|
||||
return get_or_create_personal_workspace(user)
|
||||
return None
|
||||
|
||||
|
||||
def user_workspaces(user):
|
||||
if not user.is_authenticated:
|
||||
return []
|
||||
return list(user.team_memberships.values_list('team_id', flat=True))
|
||||
return WorkspaceMembership.objects.filter(user=user).select_related('workspace').order_by('workspace__name')
|
||||
|
||||
|
||||
def visible_items(user, api_key=None):
|
||||
qs = Item.objects.select_related('owner', 'team').prefetch_related('tags', 'attachments', 'linked_items')
|
||||
def active_workspace(request):
|
||||
if not request.user.is_authenticated:
|
||||
return None
|
||||
workspace_id = request.session.get('workspace_id')
|
||||
if workspace_id:
|
||||
membership = WorkspaceMembership.objects.select_related('workspace').filter(user=request.user, workspace_id=workspace_id).first()
|
||||
if membership:
|
||||
return membership.workspace
|
||||
workspace = get_or_create_personal_workspace(request.user)
|
||||
request.session['workspace_id'] = workspace.id
|
||||
return workspace
|
||||
|
||||
|
||||
def user_team_ids(user, workspace=None):
|
||||
if not user.is_authenticated:
|
||||
return []
|
||||
qs = user.team_memberships
|
||||
if workspace:
|
||||
qs = qs.filter(team__workspace=workspace)
|
||||
return list(qs.values_list('team_id', flat=True))
|
||||
|
||||
|
||||
def visible_items(user, api_key=None, workspace=None):
|
||||
workspace = current_workspace(user, api_key, workspace)
|
||||
qs = Item.objects.select_related('owner', 'team', 'workspace').prefetch_related('tags', 'attachments', 'linked_items')
|
||||
if workspace:
|
||||
qs = qs.filter(workspace=workspace)
|
||||
elif not user.is_authenticated:
|
||||
return qs.none()
|
||||
if api_key:
|
||||
qs = qs.filter(owner=api_key.owner)
|
||||
allowed_tags = api_key.tags.all()
|
||||
@@ -203,19 +240,20 @@ def visible_items(user, api_key=None):
|
||||
qs = qs.filter(tags__in=allowed_tags).distinct()
|
||||
return qs
|
||||
if user.is_authenticated:
|
||||
return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
|
||||
return qs.filter(visibility=Item.Visibility.PUBLIC)
|
||||
return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user, workspace)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
|
||||
return qs.none()
|
||||
|
||||
|
||||
def user_can_edit_item(user, item):
|
||||
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user).exists())
|
||||
|
||||
|
||||
def visible_tags(user):
|
||||
def visible_tags(user, workspace=None):
|
||||
"""Tags from items the user may see; avoids leaking private tags from other users."""
|
||||
workspace = current_workspace(user, workspace=workspace)
|
||||
if not user.is_authenticated:
|
||||
return Tag.objects.filter(items__visibility=Item.Visibility.PUBLIC).distinct().order_by('name')
|
||||
return Tag.objects.filter(items__in=visible_items(user)).distinct().order_by('name')
|
||||
return Tag.objects.none()
|
||||
return Tag.objects.filter(workspace=workspace, items__in=visible_items(user, workspace=workspace)).distinct().order_by('name')
|
||||
|
||||
|
||||
def user_can_delete_item(user, item):
|
||||
@@ -228,7 +266,7 @@ def desired_item_scope(item, user, force_private=False):
|
||||
if item.visibility == Item.Visibility.PUBLIC:
|
||||
return Item.Visibility.PUBLIC, None
|
||||
tag_names = {match.group(1).lower() for match in re.finditer(r'(?<!\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:
|
||||
return Item.Visibility.TEAM, membership.team
|
||||
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'])
|
||||
return
|
||||
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:
|
||||
item.team = membership.team
|
||||
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()
|
||||
if not token:
|
||||
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):
|
||||
@@ -285,13 +323,13 @@ def api_auth_required(view):
|
||||
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 []
|
||||
kind = request.GET.get('kind')
|
||||
status = request.GET.get('status')
|
||||
day = request.GET.get('day')
|
||||
q = request.GET.get('q', '').strip()
|
||||
items = visible_items(user)
|
||||
items = visible_items(user, workspace=workspace)
|
||||
applied_preferences = False
|
||||
if kind in dict(Item.Kind.choices):
|
||||
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)
|
||||
if q:
|
||||
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):
|
||||
@@ -333,7 +375,7 @@ def index_filtered_items(request, prefs):
|
||||
status = request.GET.get('status')
|
||||
day = request.GET.get('day')
|
||||
q = request.GET.get('q', '').strip()
|
||||
items = visible_items(request.user)
|
||||
items = visible_items(request.user, workspace=active_workspace(request))
|
||||
applied_preferences = False
|
||||
if active_tags:
|
||||
if active_tag_mode == 'all':
|
||||
@@ -443,7 +485,7 @@ def collection_sections_export(sections, level=2):
|
||||
|
||||
|
||||
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}', '']
|
||||
if collection.description:
|
||||
lines.extend([collection.description.strip(), ''])
|
||||
@@ -471,13 +513,13 @@ def index(request):
|
||||
active_tag = request.GET.get('tag')
|
||||
if active_tag and not TAG_RE.search(without_markdown_code(content)):
|
||||
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'))
|
||||
item.sync_metadata()
|
||||
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
|
||||
if request.headers.get('HX-Request'):
|
||||
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'
|
||||
return response
|
||||
return redirect('index')
|
||||
@@ -493,7 +535,7 @@ def index(request):
|
||||
query['tag'] = q[1:].strip().lower()
|
||||
query.pop('q', None)
|
||||
return redirect(f'{request.path}?{query.urlencode()}')
|
||||
items = visible_items(request.user)
|
||||
items = visible_items(request.user, workspace=active_workspace(request))
|
||||
applied_preferences = False
|
||||
if active_tags:
|
||||
if active_tag_mode == 'all':
|
||||
@@ -540,7 +582,7 @@ def index(request):
|
||||
default=Value(1), output_field=IntegerField(),
|
||||
)).order_by('due_rank', 'due_at', '-created_at')
|
||||
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_id = request.GET.get('collection') or request.POST.get('collection')
|
||||
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):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
requested_kind = Item.Kind.NOTE
|
||||
@@ -566,7 +608,7 @@ def new_item(request):
|
||||
raw = f'/{requested_kind} {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))
|
||||
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'))
|
||||
item.sync_metadata()
|
||||
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)
|
||||
return redirect(next_url)
|
||||
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, due_at, _ = parse_due_command(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'))
|
||||
item.sync_metadata()
|
||||
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)
|
||||
year = int(request.GET.get('year', selected.year))
|
||||
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)
|
||||
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)
|
||||
@@ -614,7 +656,7 @@ def journal(request):
|
||||
next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year)
|
||||
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -638,8 +680,20 @@ def set_user_language(request):
|
||||
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
|
||||
def settings_view(request):
|
||||
workspace = active_workspace(request)
|
||||
if request.method == 'POST':
|
||||
if request.POST.get('action') == 'profile':
|
||||
form = UserSettingsForm(request.POST, instance=request.user)
|
||||
@@ -654,10 +708,21 @@ def settings_view(request):
|
||||
pref_form.save()
|
||||
messages.success(request, _('Default view saved.'))
|
||||
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':
|
||||
key_form = ApiKeyForm(request.POST, user=request.user)
|
||||
key_form = ApiKeyForm(request.POST, user=request.user, workspace=workspace)
|
||||
if key_form.is_valid():
|
||||
api_key = key_form.save(commit=False)
|
||||
api_key.workspace = workspace
|
||||
api_key.owner = request.user
|
||||
api_key.save()
|
||||
key_form.save_m2m()
|
||||
@@ -675,7 +740,9 @@ def settings_view(request):
|
||||
return render(request, 'core/settings.html', {
|
||||
'profile_form': UserSettingsForm(instance=request.user),
|
||||
'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_invites': request.user.sent_user_invites.filter(accepted_at__isnull=True),
|
||||
'api_keys': request.user.api_keys.prefetch_related('tags'),
|
||||
@@ -728,18 +795,19 @@ def accept_user_invite(request, token):
|
||||
@login_required
|
||||
def team_list(request):
|
||||
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():
|
||||
team = team_form.save(commit=False)
|
||||
team.owner = request.user
|
||||
team.save()
|
||||
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})
|
||||
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 = []
|
||||
base_items = visible_items(request.user)
|
||||
base_items = visible_items(request.user, workspace=active_workspace(request))
|
||||
for team in teams:
|
||||
team_items = base_items.filter(team=team)
|
||||
stats.append({
|
||||
@@ -749,12 +817,12 @@ def team_list(request):
|
||||
'open_todos_count': team_items.filter(kind=Item.Kind.TODO, is_done=False).count(),
|
||||
'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
|
||||
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
|
||||
if request.method == 'POST' and membership.role in [TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]:
|
||||
invite_form = TeamInviteForm(request.POST)
|
||||
@@ -765,7 +833,7 @@ def team_detail(request, pk):
|
||||
invite.save()
|
||||
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)
|
||||
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', {
|
||||
'team': team,
|
||||
'membership': membership,
|
||||
@@ -780,18 +848,23 @@ def team_detail(request, pk):
|
||||
@login_required
|
||||
def team_accept_invite(request, token):
|
||||
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})
|
||||
request.session['workspace_id'] = invite.team.workspace_id
|
||||
invite.accepted_at = timezone.now()
|
||||
invite.save(update_fields=['accepted_at'])
|
||||
messages.success(request, _('You are now a member of team %(team)s.') % {'team': invite.team.name})
|
||||
return redirect('team_detail', pk=invite.team.pk)
|
||||
|
||||
|
||||
def visible_collections(user):
|
||||
qs = Collection.objects.select_related('owner', 'team').prefetch_related('filter_tags', 'sections__item', 'sections__sub_collection')
|
||||
def visible_collections(user, workspace=None):
|
||||
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:
|
||||
return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
|
||||
return qs.filter(visibility=Item.Visibility.PUBLIC)
|
||||
return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user, workspace)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
|
||||
return qs.none()
|
||||
|
||||
|
||||
def user_can_edit_collection(user, collection):
|
||||
@@ -844,7 +917,7 @@ def collection_target_from_form(form, user):
|
||||
|
||||
|
||||
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:
|
||||
items = items.filter(visibility=Item.Visibility.PUBLIC)
|
||||
elif collection.visibility == Item.Visibility.TEAM:
|
||||
@@ -870,20 +943,21 @@ def item_can_change_for_collection(user, item, collection):
|
||||
return True
|
||||
|
||||
|
||||
def remove_team_tags(content):
|
||||
slugs = Team.objects.values_list('slug', flat=True)
|
||||
def remove_team_tags(content, workspace):
|
||||
slugs = Team.objects.filter(workspace=workspace).values_list('slug', flat=True)
|
||||
for slug in slugs:
|
||||
content = re.sub(rf'(?<!\w)#{re.escape(slug)}\b', '', content, flags=re.I)
|
||||
return re.sub(r'[ \t]{2,}', ' ', content).strip()
|
||||
|
||||
|
||||
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:
|
||||
item.content = f'{item.content} #{collection.team.slug}'.strip()
|
||||
item.visibility = collection.visibility
|
||||
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.team = collection.team
|
||||
item.visibility = collection.visibility
|
||||
@@ -893,7 +967,7 @@ def adapt_item_for_collection(item, collection):
|
||||
|
||||
def copy_item_for_collection(item, collection, owner):
|
||||
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,
|
||||
)
|
||||
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(
|
||||
kind=Item.Kind.NOTE,
|
||||
).filter(Q(owner=user) | Q(team_id__in=user_team_ids(user))).distinct()
|
||||
workspace=workspace, kind=Item.Kind.NOTE,
|
||||
).filter(Q(owner=user) | Q(team_id__in=user_team_ids(user, workspace))).distinct()
|
||||
|
||||
|
||||
def collection_is_structured_mode(mode):
|
||||
@@ -938,8 +1013,8 @@ def collection_detail_context(request, collection, pending_item=None):
|
||||
if can_edit:
|
||||
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))
|
||||
candidates = editable_collection_notes(request.user).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))
|
||||
candidates = editable_collection_notes(request.user, workspace=collection.workspace).annotate(collection_use_count=Count('collection_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)]
|
||||
if q:
|
||||
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
|
||||
def collection_list(request):
|
||||
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':
|
||||
collections = collections.filter(mode=Collection.Mode.MANUAL)
|
||||
elif active_mode == 'tutorial':
|
||||
@@ -982,20 +1057,21 @@ def collection_list(request):
|
||||
for collection in collections:
|
||||
count = collection_tag_items(collection).count() if collection.mode == Collection.Mode.TAGS else collection.sections.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})
|
||||
|
||||
|
||||
@login_required
|
||||
def collection_create(request):
|
||||
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():
|
||||
visibility, team = collection_target_from_form(form, request.user)
|
||||
if visibility == Item.Visibility.TEAM and not team:
|
||||
form.add_error('team', _('Select a team for a team collection.'))
|
||||
else:
|
||||
collection = form.save(commit=False)
|
||||
collection.workspace = active_workspace(request)
|
||||
collection.owner = request.user
|
||||
collection.visibility = visibility
|
||||
collection.team = team
|
||||
@@ -1006,13 +1082,13 @@ def collection_create(request):
|
||||
messages.success(request, _('Collection created.'))
|
||||
return redirect(collection)
|
||||
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')})
|
||||
|
||||
|
||||
@login_required
|
||||
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):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
if request.method == 'POST':
|
||||
@@ -1053,7 +1129,7 @@ def collection_edit(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 []
|
||||
for item in tag_items:
|
||||
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
|
||||
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):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
if collection.mode == Collection.Mode.TAGS:
|
||||
@@ -1078,7 +1154,7 @@ def collection_manage(request, pk):
|
||||
if request.method == 'POST':
|
||||
action = request.POST.get('action')
|
||||
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):
|
||||
add_collection_section(collection, item)
|
||||
return redirect(redirect_url)
|
||||
@@ -1100,7 +1176,7 @@ def collection_manage(request, pk):
|
||||
title = request.POST.get('title', '').strip()[:200]
|
||||
if title:
|
||||
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,
|
||||
)
|
||||
add_collection_subcollection(collection, chapter)
|
||||
@@ -1108,7 +1184,7 @@ def collection_manage(request, pk):
|
||||
messages.error(request, _('Enter a chapter title.'))
|
||||
return redirect(redirect_url)
|
||||
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):
|
||||
messages.error(request, _('This would create a collection loop.'))
|
||||
elif not collection_subcollection_is_compatible(collection, sub_collection):
|
||||
@@ -1154,7 +1230,7 @@ def collection_manage(request, pk):
|
||||
@login_required
|
||||
@require_POST
|
||||
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):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
collection.delete()
|
||||
@@ -1164,14 +1240,14 @@ def collection_delete(request, pk):
|
||||
|
||||
@login_required
|
||||
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})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
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):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
item.is_done = not item.is_done
|
||||
@@ -1187,13 +1263,13 @@ def toggle_done(request, pk):
|
||||
|
||||
@login_required
|
||||
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})
|
||||
|
||||
|
||||
@login_required
|
||||
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):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
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)
|
||||
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.'))
|
||||
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.save()
|
||||
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['HX-Trigger'] = 'tagsChanged'
|
||||
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
|
||||
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):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
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)
|
||||
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.'))
|
||||
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.save()
|
||||
form.save_m2m()
|
||||
@@ -1245,13 +1321,13 @@ def item_editor(request, pk):
|
||||
item.sync_metadata()
|
||||
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||
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
|
||||
@require_POST
|
||||
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):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
try:
|
||||
@@ -1265,24 +1341,25 @@ def delete_item(request, pk):
|
||||
@login_required
|
||||
def kanban_list(request):
|
||||
if request.method == 'POST':
|
||||
form = KanbanForm(request.POST)
|
||||
form = KanbanForm(request.POST, user=request.user, workspace=active_workspace(request))
|
||||
if form.is_valid():
|
||||
kanban = form.save(commit=False)
|
||||
kanban.owner = request.user
|
||||
kanban.save()
|
||||
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 = []
|
||||
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()})
|
||||
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
|
||||
def kanban_detail(request, pk):
|
||||
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', {
|
||||
'kanban': kanban,
|
||||
'inbox_items': todos.filter(is_done=False, kanban_status='inbox'),
|
||||
@@ -1296,7 +1373,7 @@ def kanban_detail(request, pk):
|
||||
@require_POST
|
||||
def kanban_move(request, pk, item_pk):
|
||||
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')
|
||||
if status not in {'inbox', 'todo', 'progress', 'done'}:
|
||||
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)
|
||||
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'
|
||||
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):
|
||||
@@ -1334,12 +1411,12 @@ def set_default_item_kind(request):
|
||||
|
||||
@login_required
|
||||
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
|
||||
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):
|
||||
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()]
|
||||
@@ -1349,7 +1426,7 @@ def api_attachment_suggestions(request):
|
||||
@login_required
|
||||
def api_item_suggestions(request):
|
||||
q = request.GET.get('q', '').strip()
|
||||
items = visible_items(request.user)
|
||||
items = visible_items(request.user, workspace=active_workspace(request))
|
||||
if q:
|
||||
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]]
|
||||
@@ -1413,7 +1490,7 @@ def api_items(request):
|
||||
prefs, created = UserPreference.objects.get_or_create(user=user)
|
||||
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
|
||||
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()
|
||||
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I)))
|
||||
return JsonResponse(serialize_item(item), status=201)
|
||||
|
||||
Reference in New Issue
Block a user