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.models import Case, IntegerField, 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, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm from .models import ApiKey, Attachment, 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']*>(.*?)', 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 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 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) @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) 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) 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) item.delete() 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 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': return JsonResponse({'items': [serialize_item(i) for i in visible_items(user, request.api_key)[:200]]}) 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) item.delete() 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 item.save() item.sync_metadata() apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE) return JsonResponse(serialize_item(item))