Compare commits
5 Commits
7f203a7117
...
504f58aa36
| Author | SHA1 | Date | |
|---|---|---|---|
| 504f58aa36 | |||
| 1cd2d06b4b | |||
| a0244cf352 | |||
| d7472554df | |||
| 291c8db471 |
+3
-1
@@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, Workspace, WorkspaceMembership
|
||||
|
||||
|
||||
class CollectionSectionInline(admin.TabularInline):
|
||||
@@ -42,3 +42,5 @@ admin.site.register(TeamMembership)
|
||||
admin.site.register(TeamInvite)
|
||||
admin.site.register(Kanban)
|
||||
admin.site.register(UserPreference)
|
||||
admin.site.register(Workspace)
|
||||
admin.site.register(WorkspaceMembership)
|
||||
|
||||
@@ -1,7 +1,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
@@ -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()
|
||||
|
||||
|
||||
@@ -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')),
|
||||
]
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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' %}"><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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()}}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' %}"><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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+222
-10
@@ -9,7 +9,7 @@ from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference, Workspace, WorkspaceMembership, get_or_create_personal_workspace
|
||||
from .templatetags.markdown_extras import markdown, markdown_title
|
||||
from .views import parse_due_command, parse_quick_content
|
||||
|
||||
@@ -40,6 +40,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')
|
||||
@@ -222,8 +392,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 +437,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 +474,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 +495,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 +694,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 +1013,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 +1046,9 @@ class CollectionTests(TestCase):
|
||||
class ApiItemFilterTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(username='api-filter', password='secret')
|
||||
self.help_tag = Tag.objects.create(name='help')
|
||||
self.api_key = ApiKey.objects.create(owner=self.user, name='Help CLI')
|
||||
self.workspace = get_or_create_personal_workspace(self.user)
|
||||
self.help_tag = Tag.objects.create(workspace=self.workspace, name='help')
|
||||
self.api_key = ApiKey.objects.create(workspace=self.workspace, owner=self.user, name='Help CLI')
|
||||
self.api_key.tags.add(self.help_tag)
|
||||
|
||||
def create_item(self, content):
|
||||
|
||||
@@ -17,6 +17,7 @@ urlpatterns = [
|
||||
path('default-item-kind/', views.set_default_item_kind, name='set_default_item_kind'),
|
||||
path('settings/', views.settings_view, name='settings'),
|
||||
path('settings/api-keys/<int:pk>/delete/', views.delete_api_key, name='delete_api_key'),
|
||||
path('workspaces/<int:pk>/switch/', views.workspace_switch, name='workspace_switch'),
|
||||
path('settings/invites/<int:pk>/delete/', views.delete_user_invite, name='delete_user_invite'),
|
||||
path('teams/', views.team_list, name='team_list'),
|
||||
path('teams/<int:pk>/', views.team_detail, name='team_detail'),
|
||||
@@ -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'),
|
||||
|
||||
+235
-87
@@ -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,
|
||||
@@ -1413,9 +1559,10 @@ def api_items(request):
|
||||
prefs, created = UserPreference.objects.get_or_create(user=user)
|
||||
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
|
||||
due_at = data['due_at'] if 'due_at' in data else command_due_at
|
||||
item = Item.objects.create(owner=user, kind=data.get('kind') or kind, visibility=data.get('visibility') or visibility, content=content, url=data.get('url') or url, due_at=due_at)
|
||||
item = Item.objects.create(workspace=current_workspace(user, request.api_key), owner=user, kind=data.get('kind') or kind, visibility=data.get('visibility') or visibility, content=content, url=data.get('url') or url, due_at=due_at)
|
||||
item.sync_metadata()
|
||||
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I)))
|
||||
assign_item_from_mentions(item)
|
||||
return JsonResponse(serialize_item(item), status=201)
|
||||
|
||||
|
||||
@@ -1455,4 +1602,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))
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user