Initial my2dos Django app

This commit is contained in:
rucki
2026-08-22 13:45:40 +02:00
parent cb1931ce60
commit 4e542c2b8e
32 changed files with 1070 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
import json
import re
from functools import wraps
from html import unescape
from urllib.request import Request, urlopen
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import HttpResponse, JsonResponse
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, QuickItemForm, UserSettingsForm
from .models import ApiKey, Attachment, Item, Tag
COMMAND_RE = re.compile(r'/(todo|note|link|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)
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):
content = raw.strip()
commands = {c.lower() for c in COMMAND_RE.findall(content)}
content = COMMAND_RE.sub('', content).strip()
only_url = bool(ONLY_URL_RE.match(content))
kind = Item.Kind.TODO if 'todo' in commands else Item.Kind.LINK if 'link' in commands or only_url else Item.Kind.NOTE
visibility = Item.Visibility.PUBLIC if 'public' in 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 visible_items(user, api_key=None):
qs = Item.objects.select_related('owner').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(visibility=Item.Visibility.PUBLIC))
return qs.filter(visibility=Item.Visibility.PUBLIC)
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
@login_required
def index(request):
if request.method == 'POST':
form = QuickItemForm(request.POST, request.FILES)
if form.is_valid():
kind, visibility, content, url = parse_quick_content(form.cleaned_data['content'])
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)
item.sync_metadata()
if request.FILES.get('files'):
Attachment.objects.create(item=item, file=request.FILES['files'])
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')
tag = request.GET.get('tag')
kind = request.GET.get('kind')
status = request.GET.get('status')
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)
if tag:
items = items.filter(tags__name=tag)
if kind in dict(Item.Kind.choices):
items = items.filter(kind=kind)
if status == 'open':
items = items.filter(kind=Item.Kind.TODO, is_done=False)
elif status == 'archive':
items = items.filter(kind=Item.Kind.TODO, is_done=True)
else:
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 render(request, 'core/index.html', {
'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'active_tag': tag, 'active_kind': kind, 'active_status': status, 'q': q,
})
@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, 'Einstellungen gespeichert.')
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, f'API Key erzeugt: {api_key.token}')
return redirect('settings')
return render(request, 'core/settings.html', {
'profile_form': UserSettingsForm(instance=request.user),
'key_form': ApiKeyForm(),
'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
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(Item, pk=pk, owner=request.user, kind=Item.Kind.TODO)
item.is_done = not item.is_done
item.save(update_fields=['is_done', '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(Item, pk=pk, owner=request.user)
if request.method == 'POST':
form = EditItemForm(request.POST, request.FILES, instance=item)
if form.is_valid():
item = form.save()
item.sync_metadata()
if request.FILES.get('files'):
Attachment.objects.create(item=item, file=request.FILES['files'])
response = render(request, 'core/_item.html', {'item': item})
response['HX-Trigger'] = 'tagsChanged'
return response
return render(request, 'core/_edit_form.html', {'item': item, 'tags': Tag.objects.all()})
@login_required
@require_POST
def delete_item(request, pk):
item = get_object_or_404(Item, pk=pk, owner=request.user)
item.delete()
return JsonResponse({'ok': True})
@login_required
def tag_list(request):
return render(request, 'core/_tags.html', {'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')})
@login_required
def api_tags(request):
return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))})
@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,
'is_done': item.is_done,
'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', '')
kind, visibility, content, url = parse_quick_content(raw)
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)
item.sync_metadata()
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 item.owner != user:
return JsonResponse({'error': 'forbidden'}, status=403)
item.delete()
return JsonResponse({'ok': True})
if item.owner != user:
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])
item.save()
item.sync_metadata()
return JsonResponse(serialize_item(item))