Compare commits

8 Commits

Author SHA1 Message Date
rucki 645cf94056 Fix API item default kind 2026-09-12 11:50:29 +02:00
rucki be76e91e89 Disable browser autocomplete for due time 2026-09-11 18:29:00 +02:00
rucki 854b0035ec Allow deploy branch selection 2026-09-11 17:50:38 +02:00
rucki 504f58aa36 Add configurable due time suggestions 2026-09-11 17:36:38 +02:00
rucki 1cd2d06b4b Polish composer image insertion and edit layout 2026-09-11 15:06:20 +02:00
rucki a0244cf352 Add composer user and image suggestions 2026-09-11 14:57:10 +02:00
rucki d7472554df Add workspace due indicators and todo assignment 2026-09-11 12:19:38 +02:00
rucki 291c8db471 Introduce workspace tenancy boundaries 2026-09-11 12:05:42 +02:00
20 changed files with 907 additions and 137 deletions
+3
View File
@@ -183,3 +183,6 @@ cython_debug/
# Local my2dos project API config
.my2dos
# Local deployment branch override
.deploy-branch
+3 -1
View File
@@ -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)
+27 -1
View File
@@ -1,7 +1,33 @@
from datetime import datetime, time
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from .models import UserPreference
from .models import Item, 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
memberships = list(WorkspaceMembership.objects.filter(user=request.user).select_related('workspace').order_by('workspace__name'))
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59)))
for membership in memberships:
team_ids = request.user.team_memberships.filter(team__workspace=membership.workspace).values_list('team_id', flat=True)
membership.due_count = Item.objects.filter(
workspace=membership.workspace, kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end,
).filter(Q(owner=request.user) | Q(assignee=request.user) | Q(team_id__in=team_ids) | Q(visibility=Item.Visibility.PUBLIC)).distinct().count()
return {
'active_workspace': workspace,
'workspace_memberships': memberships,
}
def overview_navigation(request):
+49 -12
View File
@@ -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):
@@ -57,7 +57,7 @@ class UserSettingsForm(forms.ModelForm):
class UserPreferenceForm(forms.ModelForm):
class Meta:
model = UserPreference
fields = ['language', 'show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'due_first', 'overview_lines']
fields = ['language', 'show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'due_first', 'overview_lines', 'due_time_suggestions']
labels = {
'language': _('Language'),
'show_notes': _('Show notes'),
@@ -66,13 +66,29 @@ class UserPreferenceForm(forms.ModelForm):
'show_links': _('Show links'),
'due_first': _('Always show due items first'),
'overview_lines': _('Lines in overview'),
'due_time_suggestions': _('Time suggestions for tasks'),
}
widgets = {
'language': forms.Select(attrs={'class': 'form-select'}),
'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50}),
'due_time_suggestions': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': '08:00 Morgen\n12:00 Mittag\n17:00 Abend'}),
}
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 +112,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 +127,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 +149,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 +161,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 +170,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 +225,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()
+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')),
]
+21
View File
@@ -0,0 +1,21 @@
# Generated by Pi coding agent
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('core', '0022_workspace_tenancy'),
]
operations = [
migrations.AddField(
model_name='item',
name='assignee',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_items', to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,18 @@
# Generated by Pi coding agent
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0023_item_assignee'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='due_time_suggestions',
field=models.TextField(blank=True, default=''),
),
]
+88 -8
View File
@@ -35,17 +35,63 @@ def without_markdown_code(text):
def delete_unused_tags(tags=None):
"""Delete tags that are no longer used by items or tag-based features."""
candidates = Tag.objects.all() if tags is None else Tag.objects.filter(pk__in=[tag.pk for tag in tags])
candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True).exclude(
name__in=Team.objects.values_list('slug', flat=True),
).delete()
for tag in candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True):
if not Team.objects.filter(workspace=tag.workspace, slug=tag.name).exists():
tag.delete()
class Tag(models.Model):
name = models.SlugField(max_length=80, unique=True, allow_unicode=True)
class Workspace(models.Model):
name = models.CharField(max_length=120)
slug = models.SlugField(max_length=80, unique=True, allow_unicode=True)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_workspaces')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class WorkspaceMembership(models.Model):
class Role(models.TextChoices):
OWNER = 'owner', 'Owner'
ADMIN = 'admin', 'Admin'
MEMBER = 'member', _('Member')
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='workspace_memberships')
role = models.CharField(max_length=10, choices=Role.choices, default=Role.MEMBER)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [('workspace', 'user')]
def __str__(self):
return f'{self.user} in {self.workspace}'
def personal_workspace_slug(user):
return f'user-{user.pk}'
def get_or_create_personal_workspace(user):
workspace, _created = Workspace.objects.get_or_create(
slug=personal_workspace_slug(user),
defaults={'owner': user, 'name': user.get_full_name() or user.get_username() or f'User {user.pk}'},
)
WorkspaceMembership.objects.get_or_create(workspace=workspace, user=user, defaults={'role': WorkspaceMembership.Role.OWNER})
return workspace
class Tag(models.Model):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='tags')
name = models.SlugField(max_length=80, allow_unicode=True)
class Meta:
ordering = ['name']
constraints = [models.UniqueConstraint(fields=['workspace', 'name'], name='unique_workspace_tag_name')]
def __str__(self):
return f'#{self.name}'
@@ -70,13 +116,20 @@ class UserInvite(models.Model):
class Team(models.Model):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='teams')
name = models.CharField(max_length=120)
slug = models.SlugField(max_length=80, unique=True, allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos')
slug = models.SlugField(max_length=80, allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos')
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_teams')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
constraints = [models.UniqueConstraint(fields=['workspace', 'slug'], name='unique_workspace_team_slug')]
def save(self, *args, **kwargs):
if not self.workspace_id and self.owner_id:
self.workspace = get_or_create_personal_workspace(self.owner)
super().save(*args, **kwargs)
def __str__(self):
return self.name
@@ -132,7 +185,9 @@ 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')
assignee = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True, related_name='assigned_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)
content = models.TextField()
@@ -170,18 +225,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 +254,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 +276,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 +294,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
@@ -279,12 +355,14 @@ class UserPreference(models.Model):
show_journal = models.BooleanField(default=False)
due_first = models.BooleanField(default=True)
overview_lines = models.PositiveSmallIntegerField(default=4)
due_time_suggestions = models.TextField(blank=True, default='')
def __str__(self):
return f'Einstellungen {self.user}'
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 +374,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)
+2 -2
View File
@@ -11,18 +11,18 @@
</div>
</div>
<textarea class="form-control" name="content" rows="5" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea>
<textarea class="form-control" name="comment" rows="3" placeholder="{% trans 'Comment, e.g. same task already captured [[12]]' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
<div class="slash-menu composer-slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><div><div x-show="cmd.group && (idx === 0 || filtered[idx-1].group !== cmd.group)" class="slash-group-header" x-text="cmd.group"></div><button type="button" class="list-group-item list-group-item-action w-100 text-start" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></div></template>
</div>
{% include 'core/_format_toolbar.html' %}
<textarea class="form-control" name="comment" rows="3" placeholder="{% trans 'Comment, e.g. same task already captured [[12]]' %}" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
<div class="row g-2">
<div class="col-md-4"><select class="form-select" name="visibility">
<option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option>
{% if item.visibility == 'team' %}<option value="team" selected>{% trans "Team" %}{% if item.team %}: {{ item.team.name }}{% endif %}</option>{% endif %}
<option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</option>
</select></div>
{% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}"><datalist id="due-times-{{ item.id }}"><option value="00:00"><option value="00:30"><option value="01:00"><option value="01:30"><option value="02:00"><option value="02:30"><option value="03:00"><option value="03:30"><option value="04:00"><option value="04:30"><option value="05:00"><option value="05:30"><option value="06:00"><option value="06:30"><option value="07:00"><option value="07:30"><option value="08:00"><option value="08:30"><option value="09:00"><option value="09:30"><option value="10:00"><option value="10:30"><option value="11:00"><option value="11:30"><option value="12:00"><option value="12:30"><option value="13:00"><option value="13:30"><option value="14:00"><option value="14:30"><option value="15:00"><option value="15:30"><option value="16:00"><option value="16:30"><option value="17:00"><option value="17:30"><option value="18:00"><option value="18:30"><option value="19:00"><option value="19:30"><option value="20:00"><option value="20:30"><option value="21:00"><option value="21:30"><option value="22:00"><option value="22:30"><option value="23:00"><option value="23:30"></datalist></div>{% endif %}
{% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}" autocomplete="off"><datalist id="due-times-{{ item.id }}">{% for option in due_time_options %}<option value="{{ option.value }}"{% if option.label %} label="{{ option.label }}"{% endif %}>{% endfor %}</datalist></div>{% endif %}
<div class="col-md-4"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"></div>
</div>
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
+1
View File
@@ -8,6 +8,7 @@
<span class="badge {% if item.kind == 'todo' %}text-bg-primary{% elif item.kind == 'link' %}text-bg-success{% elif item.kind == 'journal' %}text-bg-info{% else %}text-bg-secondary{% endif %}">{{ item.get_kind_display }}</span>
{% if item.team %}<a class="badge text-bg-warning text-decoration-none" href="{% url 'team_detail' item.team.id %}">{{ item.team.name }}</a>{% else %}<span class="badge text-bg-light">{{ item.get_visibility_display }}</span>{% endif %}
<a class="text-muted small" href="{{ item.get_absolute_url }}?next={{ request.get_full_path|urlencode }}">#{{ item.id }}</a>
{% if item.assignee %}<span class="badge text-bg-light">@{{ item.assignee.username }}</span>{% endif %}
{% for tag in item.tags.all %}<a class="badge rounded-pill text-bg-light text-decoration-none tag" href="/?tag={{ tag.name }}">#{{ tag.name }}</a>{% endfor %}
</div>
<div x-data="{expanded:false,showMore:false,check(){this.$nextTick(()=>{let el=this.$refs.content;this.showMore=el && el.scrollHeight > el.clientHeight + 2})}}" x-init="check()" class="position-relative">
+11 -6
View File
@@ -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 d-flex justify-content-between gap-2 {% if active_workspace.id == membership.workspace.id %}active{% endif %}"><span>{% if active_workspace.id == membership.workspace.id %}✓ {% endif %}{{ membership.workspace.name }}</span>{% if membership.due_count %}<span class="badge text-bg-danger">{{ membership.due_count }}</span>{% endif %}</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>
@@ -46,20 +51,20 @@
document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'});
window.quickComposer = window.quickComposer || function(tags, itemId=null){
return {
open:false,showTags:false,showFormatting:false,text:'',active:0,query:'',queryStart:0,currentEl:null,preview:null,pendingFiles:[],linkSuggestions:[],attachmentSuggestions:[],itemId:itemId,tags:tags||[],
open:false,showTags:false,showFormatting:false,text:'',active:0,query:'',queryStart:0,currentEl:null,preview:null,pendingFiles:[],linkSuggestions:[],userSuggestions:[],attachmentSuggestions:[],itemId:itemId,tags:tags||[],
init(){document.body.addEventListener('tagsChanged',()=>{fetch('/api/tags/').then(r=>r.json()).then(d=>{this.tags=d.tags||[]})})},
commands:[{token:'/todo',label:'{% trans "Create task" %}',group:'{% trans "Type" %}'},{token:'/note',label:'{% trans "Create note" %}',group:'{% trans "Type" %}'},{token:'/link',label:'{% trans "Save link" %}',group:'{% trans "Type" %}'},{token:'/journal',label:'{% trans "Journal entry" %}',group:'{% trans "Type" %}'},{token:'/public',label:'{% trans "public" %}',group:'{% trans "Visibility" %}'},{token:'/private',label:'{% trans "private" %}',group:'{% trans "Visibility" %}'},{token:'/datum',label:'{% trans "Due date (DD.MM.YY)" %}',group:'{% trans "Date" %}'},{token:'/morgen',label:'{% trans "Tomorrow" %}',group:'{% trans "Date" %}'},{token:'/übermorgen',label:'{% trans "Day after tomorrow" %}',group:'{% trans "Date" %}'},{token:'/nächstewoche',label:'{% trans "Next week" %}',group:'{% trans "Date" %}'},{token:'/code',label:'{% trans "Insert Markdown code block" %}',group:'{% trans "Formatting" %}'},{token:'[[',label:'{% trans "Search internal task/note" %}',group:'{% trans "References" %}'},{token:'![[',label:'{% trans "Insert image from attachments" %}',group:'{% trans "References" %}'}],
get filtered(){let q=this.query.toLowerCase();let selectedType=(this.text.match(/^\/(todo|note|link|journal)(?=\s)/i)||[])[1]?.toLowerCase();let typeTokens=['/todo','/note','/link','/journal'];let dateTokens=['/datum','/morgen','/übermorgen','/nächstewoche'];let commands=this.commands.filter(c=>{if(typeTokens.includes(c.token))return !selectedType&&this.queryStart===0;if(this.queryStart===0&&!selectedType)return false;if(selectedType&&selectedType!=='todo'&&dateTokens.includes(c.token))return false;return true});let list=q.startsWith('#')?this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag',group:'{% trans "Tags" %}'})):q.startsWith('![[')?(this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`{% trans "Image:" %} ${a.name}`,group:'{% trans "References" %}'})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`,group:'{% trans "References" %}'}))):q.startsWith('[[')?this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`,group:'{% trans "References" %}'})):commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));return list.length?list:[{token:this.query,label:'neu',group:''}]},
commands:[{token:'/todo',label:'{% trans "Create task" %}',group:'{% trans "Type" %}'},{token:'/note',label:'{% trans "Create note" %}',group:'{% trans "Type" %}'},{token:'/link',label:'{% trans "Save link" %}',group:'{% trans "Type" %}'},{token:'/journal',label:'{% trans "Journal entry" %}',group:'{% trans "Type" %}'},{token:'/public',label:'{% trans "public" %}',group:'{% trans "Visibility" %}'},{token:'/private',label:'{% trans "private" %}',group:'{% trans "Visibility" %}'},{token:'/datum',label:'{% trans "Due date (DD.MM.YY)" %}',group:'{% trans "Date" %}'},{token:'/morgen',label:'{% trans "Tomorrow" %}',group:'{% trans "Date" %}'},{token:'/übermorgen',label:'{% trans "Day after tomorrow" %}',group:'{% trans "Date" %}'},{token:'/nächstewoche',label:'{% trans "Next week" %}',group:'{% trans "Date" %}'},{token:'/code',label:'{% trans "Insert Markdown code block" %}',group:'{% trans "Formatting" %}'},{token:'/image',label:'{% trans "Insert uploaded image" %}',group:'{% trans "References" %}'},{token:'[[',label:'{% trans "Search internal task/note" %}',group:'{% trans "References" %}'},{token:'![[',label:'{% trans "Insert image from attachments" %}',group:'{% trans "References" %}'}],
get filtered(){let q=this.query.toLowerCase();let selectedType=(this.text.match(/^\/(todo|note|link|journal)(?=\s)/i)||[])[1]?.toLowerCase();let typeTokens=['/todo','/note','/link','/journal'];let dateTokens=['/datum','/morgen','/übermorgen','/nächstewoche'];let commands=this.commands.filter(c=>{if(typeTokens.includes(c.token))return !selectedType&&this.queryStart===0;if(this.queryStart===0&&!selectedType)return false;if(selectedType&&selectedType!=='todo'&&dateTokens.includes(c.token))return false;return true});let list=q.startsWith('#')?this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag',group:'{% trans "Tags" %}'})):q.startsWith('@')?this.userSuggestions.map(u=>({token:'@'+u.username,label:u.label,group:'{% trans "Users" %}'})):q.startsWith('![[')?(this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`{% trans "Image:" %} ${a.name}`,group:'{% trans "References" %}'})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`,group:'{% trans "References" %}'}))):q.startsWith('[[')?this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`,group:'{% trans "References" %}'})):commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));return list.length?list:[{token:this.query,label:'neu',group:''}]},
isContentField(el){return el && (el.name==='content' || el.id==='id_content')},
onInput(e){this.update(e.target)},
handlePaste(e){let item=[...(e.clipboardData?.items||[])].find(i=>i.kind==='file'&&i.type.startsWith('image/'));if(!item||!this.$refs.fileInput)return;let file=item.getAsFile();let ext=(file.type.split('/')[1]||'png').replace('jpeg','jpg');let named=new File([file],`paste-${new Date().toISOString().replace(/[:.]/g,'-')}.${ext}`,{type:file.type});let dt=new DataTransfer();[...(this.$refs.fileInput.files||[])].forEach(f=>dt.items.add(f));dt.items.add(named);this.$refs.fileInput.files=dt.files;this.pendingFiles=[...dt.files].filter(f=>f.type.startsWith('image/'));this.preview=URL.createObjectURL(named)},
insertImageToken(){if(this.pendingFiles.length)this.insert(`![[paste:${this.pendingFiles[this.pendingFiles.length-1].name}]]`)},
previewFile(e){this.pendingFiles=[...e.target.files].filter(f=>f.type.startsWith('image/'));let file=this.pendingFiles[0];this.preview=file?URL.createObjectURL(file):null},
onKeydown(e){this.currentEl=e.target;let shortcut=(e.ctrlKey||e.metaKey)&&{b:'bold',i:'italic',u:'underline'}[e.key.toLowerCase()];if(shortcut){e.preventDefault();this.formatSelection(shortcut);return}if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();this.open=false;e.target.form.requestSubmit();return}if(this.open&&e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%this.filtered.length;return}if(this.open&&e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+this.filtered.length)%this.filtered.length;return}if(this.open&&(e.key==='Enter'||e.key==='Tab')&&!e.shiftKey){e.preventDefault();this.insert(this.filtered[this.active].token);return}if(e.key==='Escape'){this.open=false;return}if(e.key===' '){this.open=false;return}setTimeout(()=>this.update(e.target),0)},
update(el){this.currentEl=el;if(this.isContentField(el))this.text=el.value;let before=el.value.slice(0,el.selectionStart);let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];this.query=fragment;this.queryStart=el.selectionStart-fragment.length;this.active=0;this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[')||fragment.startsWith('![[');if(fragment.startsWith('![[')&&this.itemId){fetch(`/api/attachment-suggestions/?item=${this.itemId}`).then(r=>r.json()).then(d=>{this.attachmentSuggestions=d.attachments||[]})}else if(fragment.startsWith('[[')){fetch(`/api/item-suggestions/?q=${encodeURIComponent(fragment.slice(2))}`).then(r=>r.json()).then(d=>{this.linkSuggestions=d.items||[]})}},
update(el){this.currentEl=el;if(this.isContentField(el))this.text=el.value;let before=el.value.slice(0,el.selectionStart);let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];this.query=fragment;this.queryStart=el.selectionStart-fragment.length;this.active=0;this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('@')||fragment.startsWith('[[')||fragment.startsWith('![[');if(fragment.startsWith('@')){fetch(`/api/user-suggestions/?q=${encodeURIComponent(fragment.slice(1))}`).then(r=>r.json()).then(d=>{this.userSuggestions=d.users||[]})}else if(fragment.startsWith('![[')&&this.itemId){fetch(`/api/attachment-suggestions/?item=${this.itemId}`).then(r=>r.json()).then(d=>{this.attachmentSuggestions=d.attachments||[]})}else if(fragment.startsWith('[[')){fetch(`/api/item-suggestions/?q=${encodeURIComponent(fragment.slice(2))}`).then(r=>r.json()).then(d=>{this.linkSuggestions=d.items||[]})}},
formatSelection(kind){let el=this.currentEl&&this.currentEl.matches('textarea')?this.currentEl:this.$el.querySelector('textarea[name="content"],textarea');if(!el)return;let start=el.selectionStart??0,end=el.selectionEnd??start;let selected=el.value.slice(start,end);let wrappers={bold:['**','**','Text'],italic:['*','*','Text'],underline:['<u>','</u>','Text'],code:['`','`','Code']};let value,newStart,newEnd;if(wrappers[kind]){let [before,after,placeholder]=wrappers[kind];let inner=selected||placeholder;value=el.value.slice(0,start)+before+inner+after+el.value.slice(end);newStart=start+before.length;newEnd=newStart+inner.length}else{let prefixes={heading:'## ',quote:'> ',list:'- ',numbered:'1. '};let prefix=prefixes[kind]||'';let inner=selected||'{% trans "Text" %}';let formatted=inner.split('\n').map(line=>prefix+line).join('\n');value=el.value.slice(0,start)+formatted+el.value.slice(end);newStart=start;newEnd=start+formatted.length}el.value=value;if(this.isContentField(el))this.text=value;this.$nextTick(()=>{el.focus();el.setSelectionRange(newStart,newEnd)})},
addToken(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;let pos=el.selectionStart??el.value.length;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let prefix=before&& !before.endsWith(' ') && !before.endsWith('\n')?' ':'';let value=before+prefix+token+' '+after;if(this.isContentField(el))this.text=value;el.value=value;this.showTags=false;this.$nextTick(()=>{let p=before.length+prefix.length+token.length+1;el.focus();el.setSelectionRange(p,p)})},
insert(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;let isCode=token==='/code';if(isCode)token='```\n\n```';let pos=el.selectionStart;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let match=before.match(/(^|\s)([^\s]*)$/);let start=match?pos-match[2].length:pos;let needsPrefix=start>0&&el.value[start-1]!=='\n'&&el.value[start-1]!==' ';let prefix=isCode?(start>0&&el.value[start-1]!=='\n'?'\n':''):(needsPrefix?' ':'');let suffix=isCode?'': ' ';let value=el.value.slice(0,start)+prefix+token+suffix+after;if(this.isContentField(el))this.text=value;el.value=value;this.open=false;this.$nextTick(()=>{let p=isCode?start+prefix.length+4:start+prefix.length+token.length+suffix.length;el.focus();el.setSelectionRange(p,p)})}
insert(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;let isCode=token==='/code';let isImage=token==='/image';if(isCode)token='```\n\n```';if(isImage)token=this.pendingFiles.length?`![[paste:${this.pendingFiles[this.pendingFiles.length-1].name}]]`:'![[';let pos=el.selectionStart;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let match=before.match(/(^|\s)([^\s]*)$/);let start=match?pos-match[2].length:pos;let needsPrefix=start>0&&el.value[start-1]!=='\n'&&el.value[start-1]!==' ';let prefix=isCode?(start>0&&el.value[start-1]!=='\n'?'\n':''):(needsPrefix?' ':'');let suffix=(isCode||token==='![[')?'':' ';let value=el.value.slice(0,start)+prefix+token+suffix+after;if(this.isContentField(el))this.text=value;el.value=value;this.open=false;this.$nextTick(()=>{let p=isCode?start+prefix.length+4:start+prefix.length+token.length+suffix.length;el.focus();el.setSelectionRange(p,p)})}
}
};
window.searchBox = function(tags){return{open:false,active:0,q:'',tags:tags||[],get filtered(){let q=this.q.toLowerCase();return this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q))},onInput(e){this.q=e.target.value;this.open=this.q.startsWith('#')},onKeydown(e){if(!this.open)return;if(e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%Math.max(this.filtered.length,1)}else if(e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+Math.max(this.filtered.length,1))%Math.max(this.filtered.length,1)}else if(e.key==='Enter'||e.key==='Tab'){if(this.filtered.length){e.preventDefault();this.select(this.filtered[this.active])}}else if(e.key==='Escape'){this.open=false}},select(tag){let url=new URL(window.location.href);url.searchParams.set('tag',tag);url.searchParams.delete('q');window.location.href=url.pathname+'?'+url.searchParams.toString()}}}
+11 -3
View File
@@ -158,6 +158,7 @@ function quickComposer(tags, itemId=null){
preview:null,
linkSuggestions:[],
attachmentSuggestions:[],
userSuggestions:[],
pendingFiles:[],
editingItem:false,
itemId:itemId,
@@ -185,6 +186,7 @@ function quickComposer(tags, itemId=null){
{token:'/übermorgen',label:'{% trans "Day after tomorrow" %}',group:'{% trans "Date" %}'},
{token:'/nächstewoche',label:'{% trans "Next week" %}',group:'{% trans "Date" %}'},
{token:'/code',label:'{% trans "Insert Markdown code block" %}',group:'{% trans "Formatting" %}'},
{token:'/image',label:'{% trans "Insert uploaded image" %}',group:'{% trans "References" %}'},
{token:'[[',label:'{% trans "Search internal task/note" %}',group:'{% trans "References" %}'},
{token:'![[',label:'{% trans "Insert image from attachments" %}',group:'{% trans "References" %}'}
],
@@ -201,6 +203,8 @@ function quickComposer(tags, itemId=null){
});
let list=q.startsWith('#')
? this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag',group:'{% trans "Tags" %}'}))
: q.startsWith('@')
? this.userSuggestions.map(u=>({token:'@'+u.username,label:u.label,group:'{% trans "Users" %}'}))
: q.startsWith('![[')
? (this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`{% trans "Image:" %} ${a.name}`,group:'{% trans "References" %}'})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`,group:'{% trans "References" %}'})))
: q.startsWith('[[')
@@ -275,8 +279,10 @@ function quickComposer(tags, itemId=null){
this.query=fragment;
this.queryStart=el.selectionStart-fragment.length;
this.active=0;
this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[')||fragment.startsWith('![[');
if(fragment.startsWith('![[')&&this.itemId){
this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('@')||fragment.startsWith('[[')||fragment.startsWith('![[');
if(fragment.startsWith('@')){
fetch(`{% url "api_user_suggestions" %}?q=${encodeURIComponent(fragment.slice(1))}`).then(r=>r.json()).then(data=>{this.userSuggestions=data.users||[]});
}else if(fragment.startsWith('![[')&&this.itemId){
fetch(`/api/attachment-suggestions/?item=${this.itemId}`).then(r=>r.json()).then(data=>{this.attachmentSuggestions=data.attachments||[]});
}else if(fragment.startsWith('[[')){
let q=fragment.slice(2);
@@ -327,7 +333,9 @@ function quickComposer(tags, itemId=null){
let el=this.currentEl || document.getElementById('id_content');
if(!el) return;
let isCode=token==='/code';
let isImage=token==='/image';
if(isCode) token='```\n\n```';
if(isImage) token=this.pendingFiles.length?`![[paste:${this.pendingFiles[this.pendingFiles.length-1].name}]]`:'![[';
let pos=el.selectionStart;
let before=el.value.slice(0,pos);
let after=el.value.slice(pos);
@@ -335,7 +343,7 @@ function quickComposer(tags, itemId=null){
let start=match?pos-match[2].length:pos;
let needsPrefix=start>0&&el.value[start-1]!=='\n'&&el.value[start-1]!==' ';
let prefix=isCode?(start>0&&el.value[start-1]!=='\n'?'\n':''):(needsPrefix?' ':'');
let suffix=isCode?'':' ';
let suffix=(isCode||token==='![[')?'':' ';
let value=el.value.slice(0,start)+prefix+token+suffix+after;
if(this.isContentField(el)) this.text=value;
el.value=value;
+1 -1
View File
@@ -24,7 +24,7 @@
{% include 'core/_format_toolbar.html' %}
<div class="row g-2">
<div class="col-md-4"><select class="form-select" name="visibility"><option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option>{% if item.visibility == 'team' %}<option value="team" selected>{% trans "Team" %}{% if item.team %}: {{ item.team.name }}{% endif %}</option>{% endif %}<option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</option></select></div>
{% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}"><datalist id="due-times-{{ item.id }}"><option value="00:00"><option value="00:30"><option value="01:00"><option value="01:30"><option value="02:00"><option value="02:30"><option value="03:00"><option value="03:30"><option value="04:00"><option value="04:30"><option value="05:00"><option value="05:30"><option value="06:00"><option value="06:30"><option value="07:00"><option value="07:30"><option value="08:00"><option value="08:30"><option value="09:00"><option value="09:30"><option value="10:00"><option value="10:30"><option value="11:00"><option value="11:30"><option value="12:00"><option value="12:30"><option value="13:00"><option value="13:30"><option value="14:00"><option value="14:30"><option value="15:00"><option value="15:30"><option value="16:00"><option value="16:30"><option value="17:00"><option value="17:30"><option value="18:00"><option value="18:30"><option value="19:00"><option value="19:30"><option value="20:00"><option value="20:30"><option value="21:00"><option value="21:30"><option value="22:00"><option value="22:30"><option value="23:00"><option value="23:30"></datalist></div>{% endif %}
{% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}" autocomplete="off"><datalist id="due-times-{{ item.id }}">{% for option in due_time_options %}<option value="{{ option.value }}"{% if option.label %} label="{{ option.label }}"{% endif %}>{% endfor %}</datalist></div>{% endif %}
<div class="col-md-4"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"></div>
</div>
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
+1
View File
@@ -10,6 +10,7 @@
<h1 class="h5 m-0">{% if collection %}{% blocktrans with title=collection.title %}Create note for {{ title }}{% endblocktrans %}{% elif requested_kind == 'todo' %}{% trans "Create task" %}{% elif requested_kind == 'link' %}{% trans "Create link" %}{% elif requested_kind == 'journal' %}{% trans "Create journal entry" %}{% else %}{% trans "Create note" %}{% endif %}</h1>
<div class="d-flex gap-2"><button class="btn btn-success px-3 py-2" title="{% trans 'Save' %}">✓</button><a class="btn btn-danger px-3 py-2" href="{{ next_url }}" title="{% trans 'Cancel' %}">×</a></div>
</div>
<div class="small text-muted">{% trans "Type:" %} {% if requested_kind == 'todo' %}{% trans "Task" %}{% elif requested_kind == 'link' %}{% trans "Link" %}{% elif requested_kind == 'journal' %}{% trans "Journal" %}{% else %}{% trans "Note" %}{% endif %}</div>
<div class="mobile-tag-picker">
<div class="small text-muted mb-1">{% trans "Add tags" %}</div>
<div class="d-flex flex-wrap gap-2 p-2 bg-light rounded">
+18 -1
View File
@@ -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>
@@ -43,6 +44,7 @@
<div class="form-label mb-2">{% trans "Sorting" %}</div>
<label class="form-check border rounded p-3"><input class="form-check-input" type="checkbox" name="due_first" {% if preference_form.due_first.value %}checked{% endif %}> <span class="form-check-label fw-semibold">{{ preference_form.due_first.label }}</span><div class="small text-muted">{% trans "Overdue and due-today tasks are sorted to the top of the overview." %}</div></label>
</div>
<div class="col-12"><label class="form-label">{{ preference_form.due_time_suggestions.label }}</label>{{ preference_form.due_time_suggestions }}<div class="form-text">{% trans "Optional: one time per line, with an optional label, e.g. '08:00 Morning'. Leave empty for 30-minute steps." %}</div></div>
</div>
<div>
<div class="form-label mb-2">{% trans "Show in overview" %}</div>
@@ -58,6 +60,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 +90,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>
+248 -10
View File
@@ -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,176 @@ 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_workspace_switcher_shows_due_counts_for_other_workspaces(self):
user = get_user_model().objects.create_user(username='workspace-due', password='secret')
personal = get_or_create_personal_workspace(user)
company = Workspace.objects.create(owner=user, name='Company Due', slug='company-due')
WorkspaceMembership.objects.create(workspace=company, user=user, role=WorkspaceMembership.Role.OWNER)
due = timezone.make_aware(datetime.combine(timezone.localdate(), time(12, 0)))
Item.objects.create(workspace=company, owner=user, kind=Item.Kind.TODO, content='Due elsewhere', due_at=due)
self.client.force_login(user)
self.client.post(reverse('workspace_switch', args=[personal.id]), {'next': reverse('index')})
response = self.client.get(reverse('index'))
self.assertContains(response, 'Company Due')
self.assertContains(response, 'text-bg-danger">1</span>')
self.assertNotContains(response, 'Due elsewhere')
def test_user_suggestions_are_limited_to_active_workspace(self):
owner = get_user_model().objects.create_user(username='suggest-owner', password='secret')
alice = get_user_model().objects.create_user(username='alice-suggest', first_name='Alice', password='secret')
outsider = get_user_model().objects.create_user(username='alice-foreign', password='secret')
workspace = Workspace.objects.create(owner=owner, name='Suggest Co', slug='suggest-co')
WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER)
WorkspaceMembership.objects.create(workspace=workspace, user=alice, role=WorkspaceMembership.Role.MEMBER)
get_or_create_personal_workspace(outsider)
self.client.force_login(owner)
self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')})
response = self.client.get(reverse('api_user_suggestions'), {'q': 'alice'})
self.assertEqual(response.status_code, 200)
usernames = [entry['username'] for entry in response.json()['users']]
self.assertIn('alice-suggest', usernames)
self.assertNotIn('alice-foreign', usernames)
def test_composer_has_user_mentions_and_image_slash_command(self):
user = get_user_model().objects.create_user(username='composer-menu', password='secret')
self.client.force_login(user)
response = self.client.get(reverse('index'))
self.assertContains(response, '/image')
self.assertContains(response, '/api/user-suggestions/')
self.assertContains(response, "fragment.startsWith('@')")
self.assertContains(response, "token==='![[")
def test_todo_can_be_assigned_to_workspace_member_with_mention(self):
owner = get_user_model().objects.create_user(username='assign-owner', password='secret')
assignee = get_user_model().objects.create_user(username='alice', password='secret')
workspace = Workspace.objects.create(owner=owner, name='Assign Co', slug='assign-co')
WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER)
WorkspaceMembership.objects.create(workspace=workspace, user=assignee, role=WorkspaceMembership.Role.MEMBER)
self.client.force_login(owner)
self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')})
response = self.client.post(reverse('index'), {'content': '/todo Prepare report @alice'})
self.assertRedirects(response, reverse('index'))
item = Item.objects.get(content='Prepare report @alice')
self.assertEqual(item.workspace, workspace)
self.assertEqual(item.assignee, assignee)
def test_assigned_private_todo_is_visible_to_assignee(self):
owner = get_user_model().objects.create_user(username='assign-owner2', password='secret')
assignee = get_user_model().objects.create_user(username='bob', password='secret')
workspace = Workspace.objects.create(owner=owner, name='Assign Co 2', slug='assign-co-2')
WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER)
WorkspaceMembership.objects.create(workspace=workspace, user=assignee, role=WorkspaceMembership.Role.MEMBER)
Item.objects.create(workspace=workspace, owner=owner, assignee=assignee, kind=Item.Kind.TODO, content='Assigned private')
self.client.force_login(assignee)
self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')})
response = self.client.get(reverse('index'))
self.assertContains(response, 'Assigned private')
self.assertContains(response, '@bob')
def test_team_todo_assignment_requires_team_membership(self):
owner = get_user_model().objects.create_user(username='assign-owner3', password='secret')
non_team_member = get_user_model().objects.create_user(username='charlie', password='secret')
workspace = Workspace.objects.create(owner=owner, name='Assign Co 3', slug='assign-co-3')
WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER)
WorkspaceMembership.objects.create(workspace=workspace, user=non_team_member, role=WorkspaceMembership.Role.MEMBER)
team = Team.objects.create(workspace=workspace, owner=owner, name='Ops', slug='ops')
TeamMembership.objects.create(team=team, user=owner, role=TeamMembership.Role.OWNER)
self.client.force_login(owner)
self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')})
self.client.post(reverse('index'), {'content': '/todo Team task @charlie #ops'})
item = Item.objects.get(content='Team task @charlie #ops')
self.assertEqual(item.team, team)
self.assertIsNone(item.assignee)
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')
@@ -75,6 +245,8 @@ class DefaultItemKindTests(TestCase):
def test_new_item_editor_uses_user_default(self):
response = self.client.get(reverse('new_item'))
self.assertEqual(response.context['requested_kind'], Item.Kind.TODO)
self.assertContains(response, 'Typ:')
self.assertContains(response, 'Aufgabe')
class DueDateCommandTests(TestCase):
@@ -222,8 +394,42 @@ class NavigationAndLayoutTests(TestCase):
response = self.client.get(reverse('settings'))
preferences = response.content.decode().split('id="preferences"', 1)[1].split('name="due_first"', 1)[0]
self.assertEqual(preferences.count('<div class="col-12"><label class="form-label">'), 2)
self.assertContains(response, 'name="due_time_suggestions"')
self.assertNotContains(response, 'name="default_item_kind"')
def test_default_due_time_suggestions_use_half_hour_steps(self):
item = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Timed')
response = self.client.get(reverse('edit_item', args=[item.id]))
self.assertContains(response, '<option value="08:00">')
self.assertContains(response, '<option value="08:30">')
self.assertContains(response, '<option value="23:30">')
def test_custom_due_time_suggestions_are_used_in_edit_forms(self):
self.preferences.due_time_suggestions = '08:00 Morgen\n12:00 Mittag\n17:30 Abend'
self.preferences.save(update_fields=['due_time_suggestions'])
item = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Timed')
response = self.client.get(reverse('edit_item', args=[item.id]))
self.assertContains(response, '<option value="08:00" label="Morgen">')
self.assertContains(response, '<option value="12:00" label="Mittag">')
self.assertNotContains(response, '<option value="08:30">')
def test_due_time_field_still_accepts_custom_time(self):
item = Item.objects.create(owner=self.user, kind=Item.Kind.TODO, content='Timed')
due_date = timezone.localdate().isoformat()
response = self.client.post(reverse('edit_item', args=[item.id]), {
'content': item.content, 'comment': '', 'visibility': Item.Visibility.PRIVATE,
'due_date': due_date, 'due_time': '10:17',
})
self.assertEqual(response.status_code, 200)
item.refresh_from_db()
self.assertEqual(timezone.localtime(item.due_at).time().replace(second=0, microsecond=0), time(10, 17))
def test_composer_menu_is_before_toolbar_and_file_controls(self):
response = self.client.get(reverse('index'))
html = response.content.decode()
@@ -233,6 +439,14 @@ class NavigationAndLayoutTests(TestCase):
self.assertLess(menu_position, toolbar_position)
self.assertLess(toolbar_position, file_position)
def test_inline_edit_comment_is_below_format_toolbar(self):
item = Item.objects.create(owner=self.user, content='Editable')
response = self.client.get(reverse('edit_item', args=[item.id]))
html = response.content.decode()
self.assertLess(html.index('name="content"'), html.index('format-toolbar'))
self.assertLess(html.index('format-toolbar'), html.index('name="comment"'))
def test_quick_create_is_disabled_while_inline_editor_is_open(self):
response = self.client.get(reverse('index'))
self.assertContains(response, ':disabled="editingItem"')
@@ -262,7 +476,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 +497,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 +696,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 +1015,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 +1048,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):
@@ -910,6 +1124,30 @@ class ApiItemFilterTests(TestCase):
self.assertEqual(len(items), 1)
self.assertEqual(items[0]['id'], second.id)
def test_api_create_ignores_user_default_kind(self):
UserPreference.objects.create(user=self.user, default_item_kind=Item.Kind.TODO)
response = self.client.post(
reverse('api_items'), {'content': 'API note #help'},
HTTP_X_API_KEY=self.api_key.token,
)
self.assertEqual(response.status_code, 201)
item = Item.objects.get(content='API note #help')
self.assertEqual(item.kind, Item.Kind.NOTE)
def test_api_create_still_accepts_explicit_kind(self):
UserPreference.objects.create(user=self.user, default_item_kind=Item.Kind.NOTE)
response = self.client.post(
reverse('api_items'), {'content': 'API task #help', 'kind': Item.Kind.TODO},
HTTP_X_API_KEY=self.api_key.token,
)
self.assertEqual(response.status_code, 201)
item = Item.objects.get(content='API task #help')
self.assertEqual(item.kind, Item.Kind.TODO)
class CopyButtonTests(TestCase):
def test_item_has_copy_button_with_original_text(self):
+2
View File
@@ -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'),
@@ -37,6 +38,7 @@ urlpatterns = [
path('tags/', views.tag_list, name='tag_list'),
path('api/tags/', views.api_tags, name='api_tags'),
path('api/item-suggestions/', views.api_item_suggestions, name='api_item_suggestions'),
path('api/user-suggestions/', views.api_user_suggestions, name='api_user_suggestions'),
path('api/attachment-suggestions/', views.api_attachment_suggestions, name='api_attachment_suggestions'),
path('api/items/', views.api_items, name='api_items'),
path('api/items/<int:pk>/', views.api_item_detail, name='api_item_detail'),
+236 -89
View File
@@ -7,7 +7,7 @@ from html import unescape
from urllib.request import Request, urlopen
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth import get_user_model, login
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db.models import Case, Count, IntegerField, ProtectedError, Q, Value, When
@@ -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)
@@ -32,6 +32,7 @@ FIRST_LINE_URL_RE = re.compile(r'^(https?://\S+)[ \t]*(?:\r?\n|$)')
TITLE_RE = re.compile(r'<title[^>]*>(.*?)</title>', re.I | re.S)
DATE_COMMAND_RE = re.compile(r'/datum\s+(\d{1,2}\.\d{1,2}\.(?:\d{2}|\d{4}))(?:\s+(\d{1,2}:\d{2}))?\b', re.I)
RELATIVE_DATE_COMMAND_RE = re.compile(r'/(morgen|übermorgen|uebermorgen|nächstewoche|naechstewoche)\b', re.I)
MENTION_RE = re.compile(r'(?<!\w)@([\w.@+-]+)')
CODE_FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})')
INLINE_CODE_RE = re.compile(r'(`+).*?\1', re.S)
@@ -146,6 +147,33 @@ def parse_due_command(raw):
return cleaned, timezone.make_aware(datetime.combine(due_date, due_time)), True
def default_due_time_options():
return [f'{hour:02d}:{minute:02d}' for hour in range(24) for minute in (0, 30)]
def user_due_time_options(user):
try:
raw = user.preferences.due_time_suggestions
except Exception:
raw = ''
options = []
for line in (raw or '').splitlines():
line = line.strip()
if not line:
continue
match = re.match(r'^(\d{1,2}:\d{2})(?:\s+(.+))?$', line)
if not match:
continue
try:
parsed = time.fromisoformat(match.group(1))
except ValueError:
continue
value = parsed.strftime('%H:%M')
label = match.group(2).strip() if match.group(2) else ''
options.append({'value': value, 'label': label})
return options or [{'value': value, 'label': ''} for value in default_due_time_options()]
def parse_due_at_from_post(post):
due_date = (post.get('due_date') or '').strip()
due_time = (post.get('due_time') or '').strip()
@@ -188,14 +216,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', 'assignee', '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 +268,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(assignee=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())
return item.owner_id == user.id or item.assignee_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 +294,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
@@ -244,6 +310,26 @@ def item_scope_fits_collections(item, visibility, team):
) for section in sections)
def assign_item_from_mentions(item):
if item.kind != Item.Kind.TODO:
if item.assignee_id:
item.assignee = None
item.save(update_fields=['assignee', 'updated_at'])
return item
usernames = [match.group(1).lower() for match in MENTION_RE.finditer(without_markdown_code(item.content))]
assignee = None
if usernames:
members = WorkspaceMembership.objects.filter(workspace=item.workspace, user__username__in=usernames).select_related('user')
if item.team_id:
members = members.filter(user__team_memberships__team=item.team)
by_username = {membership.user.username.lower(): membership.user for membership in members}
assignee = next((by_username[name] for name in usernames if name in by_username), None)
if item.assignee_id != (assignee.id if assignee else None):
item.assignee = assignee
item.save(update_fields=['assignee', 'updated_at'])
return item
def apply_team_from_tags(item, user, force_private=False):
if force_private:
item.team = None
@@ -256,7 +342,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 +358,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 +371,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 +408,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 +423,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 +533,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 +561,14 @@ 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)))
assign_item_from_mentions(item)
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 +584,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 +631,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 +645,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,16 +657,18 @@ 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)))
assign_item_from_mentions(item)
if collection:
item = adapt_item_for_collection(item, collection)
assign_item_from_mentions(item)
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,10 +682,11 @@ 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)))
assign_item_from_mentions(item)
if request.headers.get('HX-Request'):
return render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
return redirect('journal')
@@ -602,7 +696,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 +708,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 +732,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 +760,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 +792,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 +847,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 +869,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 +885,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 +900,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 +969,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 +995,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 +1019,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 +1046,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 +1065,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 +1093,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 +1109,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 +1134,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 +1181,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 +1195,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 +1206,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 +1228,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 +1236,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 +1282,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 +1292,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 +1315,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 +1334,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)), 'due_time_options': user_due_time_options(request.user)}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
form.save_m2m()
@@ -1214,15 +1342,16 @@ def edit_item(request, pk):
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
assign_item_from_mentions(item)
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)), 'due_time_options': user_due_time_options(request.user)})
@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 +1365,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, 'due_time_options': user_due_time_options(request.user)}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
form.save_m2m()
@@ -1244,14 +1373,15 @@ def item_editor(request, pk):
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
assign_item_from_mentions(item)
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, 'due_time_options': user_due_time_options(request.user)})
@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 +1395,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 +1427,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 +1443,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 +1465,26 @@ 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_user_suggestions(request):
q = request.GET.get('q', '').strip().lstrip('@').lower()
memberships = WorkspaceMembership.objects.filter(workspace=active_workspace(request)).select_related('user')
if q:
memberships = memberships.filter(Q(user__username__icontains=q) | Q(user__first_name__icontains=q) | Q(user__last_name__icontains=q))
data = []
for membership in memberships.order_by('user__username')[:10]:
user = membership.user
label = user.get_full_name() or user.username
data.append({'username': user.username, 'label': label})
return JsonResponse({'users': data})
@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 +1494,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]]
@@ -1387,6 +1532,7 @@ def serialize_item(item):
'comment': item.comment,
'visibility': item.visibility,
'team': item.team.slug if item.team else None,
'assignee': item.assignee.username if item.assignee else None,
'is_done': item.is_done,
'completed_at': item.completed_at.isoformat() if item.completed_at else None,
'due_at': item.due_at.isoformat() if item.due_at else None,
@@ -1410,12 +1556,12 @@ def api_items(request):
data = json.loads(request.body or '{}') if request.content_type == 'application/json' else request.POST
raw = data.get('content', '')
raw, command_due_at, _ = parse_due_command(raw)
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, Item.Kind.NOTE)
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)))
assign_item_from_mentions(item)
return JsonResponse(serialize_item(item), status=201)
@@ -1455,4 +1601,5 @@ def api_item_detail(request, pk):
item.save()
item.sync_metadata()
apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
assign_item_from_mentions(item)
return JsonResponse(serialize_item(item))
+25 -2
View File
@@ -7,6 +7,8 @@ set -euo pipefail
#
# Optional:
# DEPLOY_REMOTE=origin DEPLOY_BRANCH=main ./deploy.sh
# ./deploy.sh workspace-tenancy
# echo workspace-tenancy > .deploy-branch
# SKIP_PIP=1 ./deploy.sh
# SKIP_COLLECTSTATIC=1 ./deploy.sh
# DEPLOY_RESTART_CMD="..." ./deploy.sh
@@ -14,7 +16,19 @@ set -euo pipefail
cd "$(dirname "$0")"
REMOTE="${DEPLOY_REMOTE:-origin}"
BRANCH="${DEPLOY_BRANCH:-$(git branch --show-current 2>/dev/null || echo main)}"
BRANCH_SOURCE="current branch"
if [[ $# -gt 0 && -n "${1:-}" ]]; then
BRANCH="$1"
BRANCH_SOURCE="command line"
elif [[ -n "${DEPLOY_BRANCH:-}" ]]; then
BRANCH="$DEPLOY_BRANCH"
BRANCH_SOURCE="DEPLOY_BRANCH"
elif [[ -f ".deploy-branch" && -n "$(tr -d '[:space:]' < .deploy-branch)" ]]; then
BRANCH="$(tr -d '[:space:]' < .deploy-branch)"
BRANCH_SOURCE=".deploy-branch"
else
BRANCH="$(git branch --show-current 2>/dev/null || echo main)"
fi
if [[ -x ".venv/bin/python" ]]; then
PY=".venv/bin/python"
@@ -28,9 +42,18 @@ else
fi
echo "==> Deploy $(pwd)"
echo "==> Branch: ${BRANCH} (${BRANCH_SOURCE})"
echo "==> Python: $($PY --version)"
echo "==> Git Pull: ${REMOTE}/${BRANCH}"
echo "==> Git Update: ${REMOTE}/${BRANCH}"
git fetch "$REMOTE" "$BRANCH"
CURRENT_BRANCH="$(git branch --show-current 2>/dev/null || true)"
if [[ "$CURRENT_BRANCH" != "$BRANCH" ]]; then
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
git checkout "$BRANCH"
else
git checkout -b "$BRANCH" --track "$REMOTE/$BRANCH"
fi
fi
git pull --ff-only "$REMOTE" "$BRANCH"
if [[ "${SKIP_PIP:-0}" != "1" ]]; then
+1
View File
@@ -43,6 +43,7 @@ TEMPLATES = [{
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'core.context_processors.overview_navigation',
'core.context_processors.active_workspace_navigation',
]},
}]
WSGI_APPLICATION = 'my2dos.wsgi.application'