Initial my2dos Django app
This commit is contained in:
+278
@@ -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))
|
||||
Reference in New Issue
Block a user