Files
my2dos/core/views.py
T
2026-08-30 18:47:35 +02:00

1137 lines
55 KiB
Python

import json
import re
from functools import wraps
from calendar import Calendar
from datetime import date, datetime, time, timedelta
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.decorators import login_required
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
from django.utils import translation
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
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I)
URL_RE = re.compile(r'https?://\S+')
ONLY_URL_RE = re.compile(r'^https?://\S+$')
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)
def fetch_page_title(url):
try:
request = Request(url, headers={'User-Agent': 'my2dos/1.0'})
with urlopen(request, timeout=4) as response:
html = response.read(200_000).decode(response.headers.get_content_charset() or 'utf-8', errors='ignore')
match = TITLE_RE.search(html)
if match:
return re.sub(r'\s+', ' ', unescape(match.group(1))).strip()
except Exception:
pass
return ''
def parse_quick_content(raw, default_kind=Item.Kind.NOTE):
content = raw.strip()
type_match = TYPE_COMMAND_RE.match(content)
type_command = type_match.group(1).lower() if type_match else None
visibility_commands = {command.lower() for command in VISIBILITY_COMMAND_RE.findall(content)}
if type_match:
content = content[type_match.end():].strip()
content = VISIBILITY_COMMAND_RE.sub('', content).strip()
only_url = bool(ONLY_URL_RE.match(content))
if type_command == 'todo':
kind = Item.Kind.TODO
elif type_command == 'journal':
kind = Item.Kind.JOURNAL
elif type_command == 'link' or only_url:
kind = Item.Kind.LINK
elif type_command == 'note':
kind = Item.Kind.NOTE
else:
kind = default_kind if default_kind in Item.Kind.values else Item.Kind.NOTE
visibility = Item.Visibility.PUBLIC if 'public' in visibility_commands else Item.Visibility.PRIVATE
url_match = URL_RE.search(content)
url = url_match.group(0) if kind == Item.Kind.LINK and url_match else ''
if only_url:
content = fetch_page_title(url) or url
return kind, visibility, content, url
def parse_due_command(raw):
"""Extract a due date slash command and return cleaned text, date and match state."""
date_match = DATE_COMMAND_RE.search(raw)
relative_match = RELATIVE_DATE_COMMAND_RE.search(raw)
match = date_match or relative_match
if not match:
return raw, None, False
due_time = time(23, 59)
if date_match:
date_format = '%d.%m.%Y' if len(date_match.group(1).rsplit('.', 1)[-1]) == 4 else '%d.%m.%y'
try:
due_date = datetime.strptime(date_match.group(1), date_format).date()
if date_match.group(2):
due_time = time.fromisoformat(date_match.group(2))
except ValueError:
return raw, None, False
else:
today = timezone.localdate()
command = relative_match.group(1).lower()
if command == 'morgen':
due_date = today + timedelta(days=1)
elif command in {'übermorgen', 'uebermorgen'}:
due_date = today + timedelta(days=2)
else:
due_date = today + timedelta(days=(7 - today.weekday()))
cleaned = f'{raw[:match.start()]} {raw[match.end():]}'
cleaned = re.sub(r'[ \t]{2,}', ' ', cleaned).strip()
return cleaned, timezone.make_aware(datetime.combine(due_date, due_time)), True
def parse_due_at_from_post(post):
due_date = (post.get('due_date') or '').strip()
due_time = (post.get('due_time') or '').strip()
if not due_date:
return None
try:
parsed_date = date.fromisoformat(due_date)
parsed_time = time.fromisoformat(due_time) if due_time else time(23, 59)
return timezone.make_aware(datetime.combine(parsed_date, parsed_time))
except ValueError:
return None
def attach_files_and_replace_tokens(item, files):
replacements = {}
for file in files:
attachment = Attachment.objects.create(item=item, file=file)
replacements[f'![[paste:{file.name}]]'] = f'![[file:{attachment.id}]]'
if replacements:
content = item.content
comment = item.comment
for old, new in replacements.items():
content = content.replace(old, new)
comment = comment.replace(old, new)
if content != item.content or comment != item.comment:
item.content = content
item.comment = comment
item.save(update_fields=['content', 'comment', 'updated_at'])
def user_team_ids(user):
if not user.is_authenticated:
return []
return list(user.team_memberships.values_list('team_id', flat=True))
def visible_items(user, api_key=None):
qs = Item.objects.select_related('owner', 'team').prefetch_related('tags', 'attachments', 'linked_items')
if api_key:
qs = qs.filter(owner=api_key.owner)
allowed_tags = api_key.tags.all()
if allowed_tags.exists():
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)
def user_can_edit_item(user, item):
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user).exists())
def 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
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äöüÄÖÜß-]+)', 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()
team_id = team.id if team else None
return all(collection_scope_allows_item(
section.collection.visibility, section.collection.team_id, section.collection.owner_id,
visibility, team_id, item.owner_id,
) for section in sections)
def apply_team_from_tags(item, user, force_private=False):
if force_private:
item.team = None
if item.visibility == Item.Visibility.TEAM:
item.visibility = Item.Visibility.PRIVATE
item.save(update_fields=['team', 'visibility', 'updated_at'])
return
if item.visibility == Item.Visibility.PUBLIC:
item.team = None
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()
if membership:
item.team = membership.team
if item.visibility == Item.Visibility.PRIVATE:
item.visibility = Item.Visibility.TEAM
else:
item.team = None
if item.visibility == Item.Visibility.TEAM:
item.visibility = Item.Visibility.PRIVATE
item.save(update_fields=['team', 'visibility', 'updated_at'])
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()
def api_auth_required(view):
@wraps(view)
def wrapper(request, *args, **kwargs):
request.api_key = get_api_key(request)
if request.user.is_authenticated or request.api_key:
return view(request, *args, **kwargs)
return JsonResponse({'error': 'authentication required'}, status=401)
return wrapper
def index(request):
if not request.user.is_authenticated:
if request.method == 'POST':
return redirect('login')
return render(request, 'core/landing.html')
prefs, created = UserPreference.objects.get_or_create(user=request.user)
if request.method == 'POST':
form = QuickItemForm(request.POST, request.FILES)
if form.is_valid():
raw = form.cleaned_data['content']
raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
active_tag = request.GET.get('tag')
if active_tag and f'#{active_tag.lower()}' not in content.lower():
content = f'{content} #{active_tag}'
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
if request.headers.get('HX-Request'):
response = render(request, 'core/_create_response.html', {'item': item, 'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')})
response['HX-Trigger'] = 'tagsChanged'
return response
return redirect('index')
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'
tag = active_tags[0] if active_tags else None
kind = request.GET.get('kind')
status = request.GET.get('status')
day = request.GET.get('day')
q = request.GET.get('q', '').strip()
if q.startswith('#') and len(q) > 1:
query = request.GET.copy()
query['tag'] = q[1:].strip().lower()
query.pop('q', None)
return redirect(f'{request.path}?{query.urlencode()}')
items = visible_items(request.user)
applied_preferences = False
if active_tags:
if active_tag_mode == 'all':
for tag_name in active_tags:
items = items.filter(tags__name=tag_name)
else:
items = items.filter(tags__name__in=active_tags).distinct()
if kind in dict(Item.Kind.choices):
items = items.filter(kind=kind)
elif not any([tag, status, q, day]):
applied_preferences = True
type_filter = Q()
if prefs.show_notes:
type_filter |= Q(kind=Item.Kind.NOTE)
if prefs.show_links:
type_filter |= Q(kind=Item.Kind.LINK)
if prefs.show_journal:
type_filter |= Q(kind=Item.Kind.JOURNAL)
if prefs.show_open_todos:
type_filter |= Q(kind=Item.Kind.TODO, is_done=False)
if prefs.show_done_todos:
type_filter |= Q(kind=Item.Kind.TODO, is_done=True)
items = items.filter(type_filter) if type_filter else items.none()
if day:
items = items.filter(created_at__date=day)
if status == 'open':
items = items.filter(kind=Item.Kind.TODO, is_done=False)
elif status == 'due':
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59)))
items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).order_by('due_at', '-created_at')
elif status == 'archive':
items = items.filter(kind=Item.Kind.TODO, is_done=True)
elif not applied_preferences:
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()
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59)))
due_count = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).count()
if prefs.due_first and status != 'archive':
items = items.annotate(due_rank=Case(
When(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end, then=Value(0)),
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': Tag.objects.all(), '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,
})
@login_required
def new_item(request):
prefs, created = UserPreference.objects.get_or_create(user=request.user)
requested_kind = request.GET.get('kind') or prefs.default_item_kind
next_url = request.GET.get('next') or request.POST.get('next') or reverse('index')
if next_url == 'index':
next_url = reverse('index')
if request.method == 'POST':
form = QuickItemForm(request.POST, request.FILES)
if form.is_valid():
raw = form.cleaned_data['content']
if requested_kind in {'todo', 'link', 'journal'} and not TYPE_COMMAND_RE.match(raw.strip()):
raw = f'/{requested_kind} {raw}'
raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
item = Item.objects.create(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)))
return redirect(next_url)
return render(request, 'core/new_item.html', {
'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url,
})
@login_required
def journal(request):
if request.method == 'POST':
form = QuickItemForm(request.POST, request.FILES)
if form.is_valid():
raw = form.cleaned_data['content']
if not TYPE_COMMAND_RE.match(raw.strip()):
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)
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
if request.headers.get('HX-Request'):
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')
today = date.today()
selected_day = request.GET.get('day') or today.isoformat()
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)
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)
items = (journal_entries | created_entries | completed_todos).distinct().order_by('-created_at')
activity_dates = set(base.filter(kind__in=[Item.Kind.JOURNAL, Item.Kind.NOTE, Item.Kind.LINK], created_at__year=year, created_at__month=month).dates('created_at', 'day'))
activity_dates |= set(base.filter(kind=Item.Kind.TODO, completed_at__year=year, completed_at__month=month).dates('completed_at', 'day'))
weeks = [[{'day': d, 'date': date(year, month, d).isoformat() if d else '', 'has_entries': d and date(year, month, d) in activity_dates} for d in week] for week in Calendar(firstweekday=0).monthdayscalendar(year, month)]
prev_month, prev_year = (12, year - 1) if month == 1 else (month - 1, year)
next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year)
prefs, created = UserPreference.objects.get_or_create(user=request.user)
return render(request, 'core/journal.html', {
'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), '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,
})
@require_POST
def set_user_language(request):
language = request.POST.get('language')
next_url = request.POST.get('next') or '/'
if language not in {'de', 'en', ''}:
language = ''
if request.user.is_authenticated:
prefs, created = UserPreference.objects.get_or_create(user=request.user)
prefs.language = language
prefs.save(update_fields=['language'])
response = redirect(next_url)
if language:
translation.activate(language)
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
else:
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
return response
@login_required
def settings_view(request):
if request.method == 'POST':
if request.POST.get('action') == 'profile':
form = UserSettingsForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
messages.success(request, _('Settings saved.'))
return redirect('settings')
elif request.POST.get('action') == 'preferences':
prefs, created = UserPreference.objects.get_or_create(user=request.user)
pref_form = UserPreferenceForm(request.POST, instance=prefs)
if pref_form.is_valid():
pref_form.save()
messages.success(request, _('Default view saved.'))
return redirect('settings')
elif request.POST.get('action') == 'apikey':
key_form = ApiKeyForm(request.POST)
if key_form.is_valid():
api_key = key_form.save(commit=False)
api_key.owner = request.user
api_key.save()
key_form.save_m2m()
messages.success(request, _('API key created: %(token)s') % {'token': api_key.token})
return redirect('settings')
elif request.POST.get('action') == 'userinvite':
invite_form = UserInviteForm(request.POST)
if invite_form.is_valid():
invite = invite_form.save(commit=False)
invite.invited_by = request.user
invite.save()
messages.success(request, _('Invitation link created: %(url)s') % {'url': request.build_absolute_uri(reverse("accept_user_invite", args=[invite.token]))})
return redirect('settings')
prefs, created = UserPreference.objects.get_or_create(user=request.user)
return render(request, 'core/settings.html', {
'profile_form': UserSettingsForm(instance=request.user),
'preference_form': UserPreferenceForm(instance=prefs),
'key_form': ApiKeyForm(),
'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'),
})
@login_required
@require_POST
def delete_api_key(request, pk):
api_key = get_object_or_404(ApiKey, pk=pk, owner=request.user)
api_key.delete()
return redirect('settings')
@login_required
@require_POST
def delete_user_invite(request, pk):
invite = get_object_or_404(UserInvite, pk=pk, invited_by=request.user, accepted_at__isnull=True)
invite.delete()
if request.headers.get('HX-Request'):
return HttpResponse('')
messages.success(request, _('Invitation deleted.'))
return redirect('settings')
def accept_user_invite(request, token):
invite = get_object_or_404(UserInvite, token=token, accepted_at__isnull=True)
if request.user.is_authenticated:
invite.accepted_at = timezone.now()
invite.save(update_fields=['accepted_at'])
messages.success(request, _('Invitation accepted.'))
return redirect('index')
if request.method == 'POST':
form = InviteRegistrationForm(request.POST)
if form.is_valid():
user = form.save(commit=False)
if invite.email and not user.email:
user.email = invite.email
user.save()
invite.accepted_at = timezone.now()
invite.save(update_fields=['accepted_at'])
login(request, user)
messages.success(request, _('Welcome to my2dos.'))
return redirect('index')
else:
form = InviteRegistrationForm(initial={'email': invite.email})
return render(request, 'core/accept_user_invite.html', {'invite': invite, 'form': form})
@login_required
def team_list(request):
if request.method == 'POST':
team_form = TeamForm(request.POST)
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)
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')
stats = []
base_items = visible_items(request.user)
for team in teams:
team_items = base_items.filter(team=team)
stats.append({
'team': team,
'members_count': team.memberships.count(),
'items_count': team_items.count(),
'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})
@login_required
def team_detail(request, pk):
membership = get_object_or_404(TeamMembership.objects.select_related('team'), team_id=pk, user=request.user)
team = membership.team
if request.method == 'POST' and membership.role in [TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]:
invite_form = TeamInviteForm(request.POST)
if invite_form.is_valid():
invite = invite_form.save(commit=False)
invite.team = team
invite.invited_by = request.user
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)
return render(request, 'core/team_detail.html', {
'team': team,
'membership': membership,
'memberships': team.memberships.select_related('user'),
'invites': team.invites.filter(accepted_at__isnull=True),
'invite_form': TeamInviteForm(),
'items': items,
'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4,
})
@login_required
def team_accept_invite(request, token):
invite = get_object_or_404(TeamInvite.objects.select_related('team'), token=token, accepted_at__isnull=True)
TeamMembership.objects.get_or_create(team=invite.team, user=request.user, defaults={'role': TeamMembership.Role.MEMBER})
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')
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_scope_allows_item(collection_visibility, collection_team_id, collection_owner_id, item_visibility, item_team_id, item_owner_id):
# A collection may reference a note that is already visible to a wider audience.
if item_visibility == Item.Visibility.PUBLIC:
return True
if collection_visibility == Item.Visibility.PUBLIC:
return False
if collection_visibility == Item.Visibility.TEAM:
return item_visibility == Item.Visibility.TEAM and item_team_id == collection_team_id
return item_visibility == Item.Visibility.PRIVATE and item_owner_id == collection_owner_id
def collection_item_is_compatible(collection, item):
return collection_scope_allows_item(
collection.visibility, collection.team_id, collection.owner_id,
item.visibility, item.team_id, item.owner_id,
)
def collection_subcollection_is_compatible(collection, sub_collection):
return collection_scope_allows_item(
collection.visibility, collection.team_id, collection.owner_id,
sub_collection.visibility, sub_collection.team_id, sub_collection.owner_id,
)
def collection_contains_collection(collection, target):
if collection.pk == target.pk:
return True
for section in collection.sections.select_related('sub_collection'):
if section.sub_collection and collection_contains_collection(section.sub_collection, target):
return True
return False
def collection_target_from_form(form, user):
team = form.cleaned_data.get('team')
if team:
return Item.Visibility.TEAM, team
return form.cleaned_data['visibility'], None
def collection_tag_items(collection):
items = Item.objects.filter(kind=Item.Kind.NOTE).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:
items = items.filter(Q(visibility=Item.Visibility.PUBLIC) | Q(visibility=Item.Visibility.TEAM, team=collection.team))
else:
items = items.filter(Q(visibility=Item.Visibility.PUBLIC) | Q(visibility=Item.Visibility.PRIVATE, owner=collection.owner))
for tag in collection.filter_tags.all():
items = items.filter(tags=tag)
return items.distinct().order_by('created_at', 'id')
def item_can_change_for_collection(user, item, collection):
if not user_can_edit_item(user, item):
return False
target_team_id = collection.team_id
for section in item.collection_sections.select_related('collection').exclude(collection=collection):
other = section.collection
if not collection_scope_allows_item(
other.visibility, other.team_id, other.owner_id,
collection.visibility, target_team_id, item.owner_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 next_collection_section_position(collection):
last_position = collection.sections.order_by('-position').values_list('position', flat=True).first()
return (last_position + 1) if last_position is not None else 0
def add_collection_section(collection, item):
return CollectionSection.objects.get_or_create(
collection=collection, item=item,
defaults={'position': next_collection_section_position(collection)},
)
def add_collection_subcollection(collection, sub_collection):
return CollectionSection.objects.get_or_create(
collection=collection, sub_collection=sub_collection,
defaults={'position': next_collection_section_position(collection)},
)
def editable_collection_notes(user):
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()
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()
collection_candidates = Collection.objects.none()
if can_edit:
candidates = editable_collection_notes(request.user).exclude(collection_sections__collection=collection)
collection_candidates = visible_collections(request.user).filter(mode=Collection.Mode.MANUAL).exclude(pk=collection.pk).exclude(containing_sections__collection=collection)
collection_candidates = [candidate for candidate in collection_candidates if collection_subcollection_is_compatible(collection, candidate) and not collection_contains_collection(candidate, collection)]
if q:
candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
collection_candidates = [candidate for candidate in collection_candidates if q.lower() in candidate.title.lower()]
candidates = candidates[:30]
collection_candidates = collection_candidates[:30]
return {
'collection': collection,
'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection'),
'can_edit': can_edit,
'can_delete': user_can_delete_collection(request.user, collection),
'candidates': candidates,
'collection_candidates': collection_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):
entries = []
for collection in visible_collections(request.user).prefetch_related('filter_tags'):
count = collection_tag_items(collection).count() if collection.mode == Collection.Mode.TAGS else collection.sections.count()
entries.append({'collection': collection, 'section_count': count})
return render(request, 'core/collection_list.html', {'entries': entries})
@login_required
def collection_create(request):
if request.method == 'POST':
form = CollectionForm(request.POST, user=request.user)
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.owner = request.user
collection.visibility = visibility
collection.team = team
collection.save()
form.save_m2m()
if collection.mode == Collection.Mode.MANUAL:
collection.filter_tags.clear()
messages.success(request, _('Collection created.'))
return redirect(collection)
else:
form = CollectionForm(user=request.user)
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, user=request.user)
if form.is_valid():
visibility, team = collection_target_from_form(form, request.user)
target_mode = form.cleaned_data['mode']
has_manual_sections = collection.mode == Collection.Mode.MANUAL and collection.sections.exists()
incompatible = target_mode == Collection.Mode.MANUAL and any(
not collection_scope_allows_item(
visibility, team.id if team else None, collection.owner_id,
section.item.visibility, section.item.team_id, section.item.owner_id,
) if section.item else not collection_scope_allows_item(
visibility, team.id if team else None, collection.owner_id,
section.sub_collection.visibility, section.sub_collection.team_id, section.sub_collection.owner_id,
)
for section in collection.sections.select_related('item', 'sub_collection')
)
if target_mode == Collection.Mode.TAGS and has_manual_sections:
form.add_error('mode', _('Remove all manual sections before changing to a tag collection.'))
elif incompatible:
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('team', _('Select a team for a team collection.'))
else:
collection = form.save(commit=False)
collection.visibility = visibility
collection.team = team
collection.save()
form.save_m2m()
if collection.mode == Collection.Mode.MANUAL:
collection.filter_tags.clear()
messages.success(request, _('Collection saved.'))
return redirect(collection)
else:
form = CollectionForm(instance=collection, user=request.user)
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)
return render(request, 'core/collection_detail.html', {
'collection': collection,
'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection') if collection.mode == Collection.Mode.MANUAL else [],
'tag_items': collection_tag_items(collection) if collection.mode == Collection.Mode.TAGS else [],
'can_edit': user_can_edit_collection(request.user, collection),
})
@login_required
def collection_manage(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 collection.mode == Collection.Mode.TAGS:
return redirect('collection_edit', pk=collection.pk)
manage_url = reverse('collection_manage', args=[collection.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'))
if collection_item_is_compatible(collection, item):
add_collection_section(collection, item)
return redirect(manage_url)
if action == 'add':
return render(request, 'core/collection_manage.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(manage_url)
item = adapt_item_for_collection(item, collection)
elif mode == 'copy':
item = copy_item_for_collection(item, collection, request.user)
else:
return redirect(manage_url)
add_collection_section(collection, item)
return redirect(manage_url)
if action == 'add_collection':
sub_collection = get_object_or_404(visible_collections(request.user), pk=request.POST.get('collection_id'), mode=Collection.Mode.MANUAL)
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):
messages.error(request, _('This collection has an incompatible visibility.'))
else:
add_collection_subcollection(collection, sub_collection)
return redirect(manage_url)
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(manage_url)
return render(request, 'core/collection_manage.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)
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)
if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
item.is_done = not item.is_done
item.completed_at = timezone.now() if item.is_done else None
item.save(update_fields=['is_done', 'completed_at', 'updated_at'])
if request.headers.get('HX-Request') and item.is_done and request.GET.get('status') != 'archive':
return HttpResponse('')
return render(request, 'core/_item.html', {'item': item})
@login_required
def item_card(request, pk):
item = get_object_or_404(visible_items(request.user), 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)
if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
if request.method == 'POST':
form = EditItemForm(request.POST, request.FILES, instance=item)
if form.is_valid():
item = form.save(commit=False)
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'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
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': Tag.objects.all()})
@login_required
def item_editor(request, pk):
item = get_object_or_404(visible_items(request.user), 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()
if request.method == 'POST':
form = EditItemForm(request.POST, request.FILES, instance=item)
if form.is_valid():
item = form.save(commit=False)
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'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
return redirect(next_url)
return render(request, 'core/item_editor.html', {'item': item, 'tags': Tag.objects.all(), 'next_url': next_url})
@login_required
@require_POST
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)
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})
@login_required
def kanban_list(request):
if request.method == 'POST':
form = KanbanForm(request.POST)
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')
stats = []
for kanban in kanbans:
qs = Item.objects.filter(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})
@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')
return render(request, 'core/kanban_detail.html', {
'kanban': kanban,
'inbox_items': todos.filter(is_done=False, kanban_status='inbox'),
'todo_items': todos.filter(is_done=False, kanban_status='todo'),
'progress_items': todos.filter(is_done=False, kanban_status='progress'),
'done_items': todos.filter(is_done=True),
})
@login_required
@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)
status = request.POST.get('status')
if status not in {'inbox', 'todo', 'progress', 'done'}:
return JsonResponse({'error': 'invalid status'}, status=400)
item.kanban_status = status
item.is_done = status == 'done'
item.completed_at = timezone.now() if item.is_done else None
item.save(update_fields=['kanban_status', 'is_done', 'completed_at', 'updated_at'])
return JsonResponse({'ok': True})
@login_required
def tag_list(request):
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': Tag.objects.all(), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode})
@login_required
def api_tags(request):
return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))})
@login_required
def api_attachment_suggestions(request):
item = get_object_or_404(visible_items(request.user), pk=request.GET.get('item'))
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()]
return JsonResponse({'attachments': data})
@login_required
def api_item_suggestions(request):
q = request.GET.get('q', '').strip()
items = visible_items(request.user)
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]]
return JsonResponse({'items': data})
def api_items_queryset(request, user, api_key=None):
items = visible_items(user, api_key)
tags = [tag.strip().lstrip('#').lower() for tag in request.GET.getlist('tag') if tag.strip()]
if tags:
if request.GET.get('tag_mode') == 'all':
for tag in tags:
items = items.filter(tags__name=tag)
else:
items = items.filter(tags__name__in=tags).distinct()
q = request.GET.get('q', '').strip()
if q:
items = items.filter(Q(content__icontains=q) | Q(comment__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
return items
def api_items_limit(request):
try:
limit = int(request.GET.get('limit', 200))
except (TypeError, ValueError):
limit = 200
return max(1, min(limit, 500))
def serialize_item(item):
return {
'id': item.id,
'kind': item.kind,
'content': item.content,
'comment': item.comment,
'visibility': item.visibility,
'team': item.team.slug if item.team 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,
'url': item.url,
'tags': [t.name for t in item.tags.all()],
'linked_items': [i.id for i in item.linked_items.all()],
'attachments': [a.file.url for a in item.attachments.all()],
'created_at': item.created_at.isoformat(),
'updated_at': item.updated_at.isoformat(),
}
@csrf_exempt
@api_auth_required
@require_http_methods(['GET', 'POST'])
def api_items(request):
user = request.api_key.owner if request.api_key else request.user
if request.method == 'GET':
items = api_items_queryset(request, user, request.api_key)[:api_items_limit(request)]
return JsonResponse({'items': [serialize_item(i) for i in items]})
data = json.loads(request.body or '{}') if request.content_type == 'application/json' else request.POST
raw = data.get('content', '')
raw, command_due_at, _ = parse_due_command(raw)
prefs, created = UserPreference.objects.get_or_create(user=user)
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
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.sync_metadata()
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I)))
return JsonResponse(serialize_item(item), status=201)
@csrf_exempt
@api_auth_required
@require_http_methods(['GET', 'PATCH', 'DELETE'])
def api_item_detail(request, pk):
user = request.api_key.owner if request.api_key else request.user
item = get_object_or_404(visible_items(user, request.api_key), pk=pk)
if request.method == 'GET':
return JsonResponse(serialize_item(item))
if request.method == 'DELETE':
if not user_can_delete_item(user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
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)
data = json.loads(request.body or '{}')
for field in ['content', 'comment', 'kind', 'visibility', 'is_done', 'due_at', 'url']:
if field in data:
setattr(item, field, data[field])
if 'content' in data and 'due_at' not in data:
item.content, command_due_at, has_due_command = parse_due_command(item.content)
if has_due_command:
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)
return JsonResponse(serialize_item(item))