Files
my2dos/core/views.py
T

1301 lines
64 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, Count, 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, TAG_RE, without_markdown_code
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+$')
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)
CODE_FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})')
INLINE_CODE_RE = re.compile(r'(`+).*?\1', re.S)
def mask_markdown_code(text):
"""Replace Markdown code with spaces while preserving string positions."""
masked_lines = []
fence = None
for line in (text or '').splitlines(keepends=True):
line_without_newline = line.rstrip('\r\n')
newline = line[len(line_without_newline):]
match = CODE_FENCE_RE.match(line_without_newline)
if fence:
masked_lines.append(' ' * len(line_without_newline) + newline)
if match and match.group(1)[0] == fence[0] and len(match.group(1)) >= len(fence):
fence = None
elif match:
fence = match.group(1)
masked_lines.append(' ' * len(line_without_newline) + newline)
else:
masked_lines.append(INLINE_CODE_RE.sub(lambda m: ' ' * (m.end() - m.start()), line_without_newline) + newline)
return ''.join(masked_lines)
def remove_spans(text, spans):
for start, end in sorted(spans, reverse=True):
text = f'{text[:start]} {text[end:]}'
return re.sub(r'[ \t]{2,}', ' ', text).strip()
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
masked_content = mask_markdown_code(content)
visibility_matches = list(VISIBILITY_COMMAND_RE.finditer(masked_content))
visibility_commands = {match.group(1).lower() for match in visibility_matches}
offset = 0
if type_match:
remainder = content[type_match.end():]
leading_space = len(remainder) - len(remainder.lstrip())
offset = type_match.end() + leading_space
content = remainder.strip()
visibility_spans = [(match.start() - offset, match.end() - offset) for match in visibility_matches if match.start() >= offset]
content = remove_spans(content, visibility_spans)
only_url = bool(ONLY_URL_RE.match(content))
first_line_url_match = FIRST_LINE_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 or first_line_url_match:
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 = first_line_url_match or URL_RE.search(content)
url = url_match.group(1) if kind == Item.Kind.LINK and first_line_url_match else (url_match.group(0) if kind == Item.Kind.LINK and url_match else '')
if first_line_url_match:
title = fetch_page_title(url) or url
extra_content = content[first_line_url_match.end():].strip()
content = f'{title}\n{extra_content}' if extra_content else title
elif 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."""
masked_raw = mask_markdown_code(raw)
date_match = DATE_COMMAND_RE.search(masked_raw)
relative_match = RELATIVE_DATE_COMMAND_RE.search(masked_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 = remove_spans(raw, [(match.start(), match.end())])
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 delete_selected_attachments(item, attachment_ids):
for attachment in item.attachments.filter(pk__in=attachment_ids):
attachment.file.delete(save=False)
attachment.delete()
if hasattr(item, '_prefetched_objects_cache'):
item._prefetched_objects_cache.pop('attachments', None)
def attach_files_and_replace_tokens(item, files):
replacements = {}
attached = False
for file in files:
attachment = Attachment.objects.create(item=item, file=file)
attached = True
replacements[f'![[paste:{file.name}]]'] = f'![[file:{attachment.id}]]'
if attached and hasattr(item, '_prefetched_objects_cache'):
item._prefetched_objects_cache.pop('attachments', None)
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äöüÄÖÜß-]+)', without_markdown_code(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_filter_tags(request, user, prefs, active_tags=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)
applied_preferences = False
if kind in dict(Item.Kind.choices):
items = items.filter(kind=kind)
elif not any([active_tags, 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':
upcoming_end = timezone.make_aware(datetime.combine(timezone.localdate() + timedelta(days=7), time(23, 59, 59)))
items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__isnull=False, due_at__lte=upcoming_end)
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()
return Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags)).distinct().order_by('name')
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, requested_default_item_kind(request.POST, prefs))
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)
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
if request.headers.get('HX-Request'):
active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()]
response = render(request, 'core/_create_response.html', {'item': item, 'tags': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags})
response['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)
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59)))
upcoming_end = timezone.make_aware(datetime.combine(timezone.localdate() + timedelta(days=7), time(23, 59, 59)))
if status == 'open':
items = items.filter(kind=Item.Kind.TODO, is_done=False)
elif status == 'due':
items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__isnull=False, due_at__lte=upcoming_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()
due_count = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).count()
due_now_items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).order_by('due_at', '-created_at') if status == 'due' else Item.objects.none()
upcoming_due_items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__gt=today_end, due_at__lte=upcoming_end).order_by('due_at', '-created_at') if status == 'due' else Item.objects.none()
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': index_filter_tags(request, request.user, prefs, active_tags), 'composer_tags': Tag.objects.all(), '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,
})
@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')
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)
if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403)
requested_kind = Item.Kind.NOTE
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, requested_default_item_kind(request.POST, prefs))
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 collection:
item = adapt_item_for_collection(item, collection)
add_collection_section(collection, item)
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, 'collection': collection,
})
@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__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:
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_is_structured_mode(mode):
return mode in Collection.STRUCTURED_MODES
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:
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))
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 = list(candidates[:30])
for candidate in candidates:
candidate.used_in_current_collection = candidate.id in used_item_ids
collection_candidates = collection_candidates[:30]
for candidate in collection_candidates:
candidate.used_in_current_collection = candidate.id in used_collection_ids
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):
active_mode = request.GET.get('mode', 'listed')
collections = visible_collections(request.user).prefetch_related('filter_tags')
if active_mode == 'manual':
collections = collections.filter(mode=Collection.Mode.MANUAL)
elif active_mode == 'tutorial':
collections = collections.filter(mode=Collection.Mode.TUTORIAL)
elif active_mode == 'tags':
collections = collections.filter(mode=Collection.Mode.TAGS)
elif active_mode == 'chapter':
collections = collections.filter(mode=Collection.Mode.CHAPTER)
else:
active_mode = 'listed'
collections = collections.exclude(mode=Collection.Mode.CHAPTER)
entries = []
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()
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)
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.TAGS:
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_is_structured_mode(collection.mode) and collection.sections.exists()
incompatible = collection_is_structured_mode(target_mode) 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.TAGS:
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.is_structured 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])
q = request.GET.get('q', '').strip()
redirect_url = f'{manage_url}?q={q}' if q else manage_url
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(redirect_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(redirect_url)
item = adapt_item_for_collection(item, collection)
elif mode == 'copy':
item = copy_item_for_collection(item, collection, request.user)
else:
return redirect(redirect_url)
add_collection_section(collection, item)
return redirect(redirect_url)
if action == 'create_chapter':
title = request.POST.get('title', '').strip()[:200]
if title:
chapter = Collection.objects.create(
owner=request.user, title=title, mode=Collection.Mode.CHAPTER,
visibility=collection.visibility, team=collection.team,
)
add_collection_subcollection(collection, chapter)
return redirect(reverse('collection_manage', args=[chapter.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'))
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(redirect_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)
elif action == 'move_to':
try:
new_position = int(request.POST.get('position', section.position))
except (TypeError, ValueError):
new_position = section.position
ordered = list(collection.sections.order_by('position', 'id'))
ordered.remove(section)
ordered.insert(max(0, min(new_position, len(ordered))), section)
with transaction.atomic():
for index, current in enumerate(ordered):
CollectionSection.objects.filter(pk=current.pk).update(position=1000 + index)
for index, current in enumerate(ordered):
CollectionSection.objects.filter(pk=current.pk).update(position=index)
return redirect(redirect_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()
delete_selected_attachments(item, request.POST.getlist('delete_attachments'))
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()
delete_selected_attachments(item, request.POST.getlist('delete_attachments'))
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):
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})
def requested_default_item_kind(post, prefs):
kind = post.get('default_item_kind') or prefs.default_item_kind
return kind if kind in Item.Kind.values else prefs.default_item_kind
@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))