Add desktop note collections
This commit is contained in:
+261
-5
@@ -9,7 +9,8 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Case, IntegerField, Q, Value, When
|
||||
from django.db import transaction
|
||||
from django.db.models import Case, IntegerField, ProtectedError, Q, Value, When
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
@@ -18,8 +19,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, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm
|
||||
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
|
||||
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
|
||||
|
||||
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
|
||||
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I)
|
||||
@@ -159,6 +160,22 @@ def user_can_delete_item(user, item):
|
||||
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists())
|
||||
|
||||
|
||||
def desired_item_scope(item, user, force_private=False):
|
||||
if force_private:
|
||||
return Item.Visibility.PRIVATE, None
|
||||
tag_names = {match.group(1).lower() for match in re.finditer(r'(?<!\w)#([\wäöüÄÖÜß-]+)', item.content)}
|
||||
membership = TeamMembership.objects.select_related('team').filter(user=user, 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
|
||||
return visibility, None
|
||||
|
||||
|
||||
def item_scope_fits_collections(item, visibility, team):
|
||||
sections = item.collection_sections.select_related('collection') if item.pk else CollectionSection.objects.none()
|
||||
return not any(section.collection.visibility != visibility or section.collection.team_id != (team.id if team else None) for section in sections)
|
||||
|
||||
|
||||
def apply_team_from_tags(item, user, force_private=False):
|
||||
if force_private:
|
||||
item.team = None
|
||||
@@ -514,6 +531,223 @@ def team_accept_invite(request, token):
|
||||
return redirect('team_detail', pk=invite.team.pk)
|
||||
|
||||
|
||||
def visible_collections(user):
|
||||
qs = Collection.objects.select_related('owner', 'team').prefetch_related('tags', 'sections__item')
|
||||
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)
|
||||
|
||||
|
||||
def user_can_edit_collection(user, collection):
|
||||
return user.is_authenticated and (collection.owner_id == user.id or (collection.team_id and TeamMembership.objects.filter(team=collection.team, user=user).exists()))
|
||||
|
||||
|
||||
def user_can_delete_collection(user, collection):
|
||||
return user.is_authenticated and (collection.owner_id == user.id or (collection.team_id and TeamMembership.objects.filter(team=collection.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists()))
|
||||
|
||||
|
||||
def collection_item_is_compatible(collection, item):
|
||||
if collection.visibility == Item.Visibility.TEAM:
|
||||
return item.visibility == Item.Visibility.TEAM and item.team_id == collection.team_id
|
||||
return item.visibility == collection.visibility and item.team_id is None
|
||||
|
||||
|
||||
def collection_target_from_form(form, user):
|
||||
tags = form.cleaned_data.get('tags')
|
||||
tag_names = list(tags.values_list('name', flat=True)) if tags is not None else []
|
||||
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
|
||||
if membership:
|
||||
return Item.Visibility.TEAM, membership.team
|
||||
return form.cleaned_data['visibility'], None
|
||||
|
||||
|
||||
def item_can_change_for_collection(user, item, collection):
|
||||
if not user_can_edit_item(user, item):
|
||||
return False
|
||||
for section in item.collection_sections.select_related('collection').exclude(collection=collection):
|
||||
other = section.collection
|
||||
if other.visibility != collection.visibility or other.team_id != collection.team_id:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def remove_team_tags(content):
|
||||
slugs = Team.objects.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)
|
||||
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.sync_metadata()
|
||||
item.team = collection.team
|
||||
item.visibility = collection.visibility
|
||||
item.save(update_fields=['team', 'visibility', 'updated_at'])
|
||||
return item
|
||||
|
||||
|
||||
def copy_item_for_collection(item, collection, owner):
|
||||
copied = Item.objects.create(
|
||||
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():
|
||||
Attachment.objects.create(item=copied, file=attachment.file.name)
|
||||
return adapt_item_for_collection(copied, collection)
|
||||
|
||||
|
||||
def add_collection_section(collection, item):
|
||||
last_position = collection.sections.order_by('-position').values_list('position', flat=True).first()
|
||||
return CollectionSection.objects.get_or_create(
|
||||
collection=collection, item=item,
|
||||
defaults={'position': (last_position + 1) if last_position is not None else 0},
|
||||
)
|
||||
|
||||
|
||||
def collection_detail_context(request, collection, pending_item=None):
|
||||
can_edit = user_can_edit_collection(request.user, collection)
|
||||
q = request.GET.get('q', '').strip()
|
||||
candidates = Item.objects.none()
|
||||
if can_edit:
|
||||
candidates = visible_items(request.user).filter(kind=Item.Kind.NOTE).exclude(collection_sections__collection=collection)
|
||||
if q:
|
||||
candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
|
||||
candidates = candidates[:30]
|
||||
return {
|
||||
'collection': collection,
|
||||
'sections': collection.sections.select_related('item').prefetch_related('item__tags', 'item__attachments'),
|
||||
'can_edit': can_edit,
|
||||
'can_delete': user_can_delete_collection(request.user, collection),
|
||||
'candidates': candidates,
|
||||
'q': q,
|
||||
'pending_item': pending_item,
|
||||
'pending_can_convert': pending_item and item_can_change_for_collection(request.user, pending_item, collection),
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def collection_list(request):
|
||||
collections = visible_collections(request.user)
|
||||
return render(request, 'core/collection_list.html', {'collections': collections})
|
||||
|
||||
|
||||
@login_required
|
||||
def collection_create(request):
|
||||
if request.method == 'POST':
|
||||
form = CollectionForm(request.POST)
|
||||
if form.is_valid():
|
||||
visibility, team = collection_target_from_form(form, request.user)
|
||||
if visibility == Item.Visibility.TEAM and not team:
|
||||
form.add_error('visibility', _('Select a team tag for a team collection.'))
|
||||
else:
|
||||
collection = form.save(commit=False)
|
||||
collection.owner = request.user
|
||||
collection.visibility = visibility
|
||||
collection.team = team
|
||||
collection.save()
|
||||
form.save_m2m()
|
||||
messages.success(request, _('Collection created.'))
|
||||
return redirect(collection)
|
||||
else:
|
||||
form = CollectionForm()
|
||||
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)
|
||||
if not user_can_edit_collection(request.user, collection):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
if request.method == 'POST':
|
||||
form = CollectionForm(request.POST, instance=collection)
|
||||
if form.is_valid():
|
||||
visibility, team = collection_target_from_form(form, request.user)
|
||||
incompatible = collection.sections.exclude(item__visibility=visibility)
|
||||
if team:
|
||||
incompatible = incompatible | collection.sections.exclude(item__team=team)
|
||||
else:
|
||||
incompatible = incompatible | collection.sections.exclude(item__team__isnull=True)
|
||||
if incompatible.exists():
|
||||
form.add_error(None, _('The collection visibility cannot change while it contains incompatible notes.'))
|
||||
elif visibility == Item.Visibility.TEAM and not team:
|
||||
form.add_error('visibility', _('Select a team tag for a team collection.'))
|
||||
else:
|
||||
collection = form.save(commit=False)
|
||||
collection.visibility = visibility
|
||||
collection.team = team
|
||||
collection.save()
|
||||
form.save_m2m()
|
||||
messages.success(request, _('Collection saved.'))
|
||||
return redirect(collection)
|
||||
else:
|
||||
form = CollectionForm(instance=collection)
|
||||
return render(request, 'core/collection_form.html', {'form': form, 'collection': collection, 'heading': _('Edit collection')})
|
||||
|
||||
|
||||
def collection_detail(request, pk):
|
||||
collection = get_object_or_404(visible_collections(request.user), pk=pk)
|
||||
if request.method == 'POST':
|
||||
if not user_can_edit_collection(request.user, collection):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
action = request.POST.get('action')
|
||||
if action in {'add', 'confirm_add'}:
|
||||
item = get_object_or_404(visible_items(request.user), pk=request.POST.get('item_id'), kind=Item.Kind.NOTE)
|
||||
if collection_item_is_compatible(collection, item):
|
||||
add_collection_section(collection, item)
|
||||
return redirect(collection)
|
||||
if action == 'add':
|
||||
return render(request, 'core/collection_detail.html', collection_detail_context(request, collection, item))
|
||||
mode = request.POST.get('mode')
|
||||
if mode == 'convert':
|
||||
if not item_can_change_for_collection(request.user, item, collection):
|
||||
messages.error(request, _('This note is used by an incompatible collection or cannot be edited. Create a copy instead.'))
|
||||
return redirect(collection)
|
||||
item = adapt_item_for_collection(item, collection)
|
||||
elif mode == 'copy':
|
||||
item = copy_item_for_collection(item, collection, request.user)
|
||||
else:
|
||||
return redirect(collection)
|
||||
add_collection_section(collection, item)
|
||||
return redirect(collection)
|
||||
section = get_object_or_404(CollectionSection, pk=request.POST.get('section_id'), collection=collection)
|
||||
if action == 'remove':
|
||||
section.delete()
|
||||
for index, remaining in enumerate(collection.sections.order_by('position', 'id')):
|
||||
if remaining.position != index:
|
||||
CollectionSection.objects.filter(pk=remaining.pk).update(position=index)
|
||||
elif action == 'title':
|
||||
section.title = request.POST.get('title', '').strip()[:200]
|
||||
section.save(update_fields=['title'])
|
||||
elif action in {'up', 'down'}:
|
||||
direction = -1 if action == 'up' else 1
|
||||
other = collection.sections.filter(position=section.position + direction).first()
|
||||
if other:
|
||||
with transaction.atomic():
|
||||
temporary = collection.sections.order_by('-position').values_list('position', flat=True).first() + 1000
|
||||
CollectionSection.objects.filter(pk=section.pk).update(position=temporary)
|
||||
CollectionSection.objects.filter(pk=other.pk).update(position=section.position)
|
||||
CollectionSection.objects.filter(pk=section.pk).update(position=other.position)
|
||||
return redirect(collection)
|
||||
return render(request, 'core/collection_detail.html', collection_detail_context(request, collection))
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def collection_delete(request, pk):
|
||||
collection = get_object_or_404(visible_collections(request.user), pk=pk)
|
||||
if not user_can_delete_collection(request.user, collection):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
collection.delete()
|
||||
messages.success(request, _('Collection deleted.'))
|
||||
return redirect('collection_list')
|
||||
|
||||
|
||||
@login_required
|
||||
def item_detail(request, pk):
|
||||
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||
@@ -552,6 +786,11 @@ def edit_item(request, pk):
|
||||
item.content, command_due_at, has_due_command = parse_due_command(item.content)
|
||||
if item.kind == Item.Kind.TODO:
|
||||
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
|
||||
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': Tag.objects.all()}, status=409)
|
||||
item.visibility, item.team = target_visibility, target_team
|
||||
item.save()
|
||||
form.save_m2m()
|
||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||
@@ -576,6 +815,11 @@ def item_editor(request, pk):
|
||||
item.content, command_due_at, has_due_command = parse_due_command(item.content)
|
||||
if item.kind == Item.Kind.TODO:
|
||||
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
|
||||
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': Tag.objects.all(), 'next_url': next_url}, status=409)
|
||||
item.visibility, item.team = target_visibility, target_team
|
||||
item.save()
|
||||
form.save_m2m()
|
||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||
@@ -591,7 +835,11 @@ def delete_item(request, pk):
|
||||
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||
if not user_can_delete_item(request.user, item):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
item.delete()
|
||||
try:
|
||||
item.delete()
|
||||
except ProtectedError:
|
||||
collections = list(item.collection_sections.select_related('collection').values_list('collection__title', flat=True))
|
||||
return JsonResponse({'error': _('This note is used in collections and cannot be deleted.'), 'collections': collections}, status=409)
|
||||
return JsonResponse({'ok': True})
|
||||
|
||||
|
||||
@@ -721,7 +969,11 @@ def api_item_detail(request, pk):
|
||||
if request.method == 'DELETE':
|
||||
if not user_can_delete_item(user, item):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
item.delete()
|
||||
try:
|
||||
item.delete()
|
||||
except ProtectedError:
|
||||
collections = list(item.collection_sections.select_related('collection').values_list('collection__title', flat=True))
|
||||
return JsonResponse({'error': _('This note is used in collections and cannot be deleted.'), 'collections': collections}, status=409)
|
||||
return JsonResponse({'ok': True})
|
||||
if not user_can_edit_item(user, item):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
@@ -735,6 +987,10 @@ def api_item_detail(request, pk):
|
||||
item.due_at = command_due_at
|
||||
if 'is_done' in data and item.kind == Item.Kind.TODO:
|
||||
item.completed_at = timezone.now() if item.is_done else None
|
||||
target_visibility, target_team = desired_item_scope(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||
if not item_scope_fits_collections(item, target_visibility, target_team):
|
||||
return JsonResponse({'error': _('This visibility is incompatible with a collection that uses the note.')}, status=409)
|
||||
item.visibility, item.team = target_visibility, target_team
|
||||
item.save()
|
||||
item.sync_metadata()
|
||||
apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||
|
||||
Reference in New Issue
Block a user