diff --git a/.gitignore b/.gitignore index 36b13f1..d442558 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,6 @@ cython_debug/ # PyPI configuration file .pypirc + +# Uploaded files +/media/ diff --git a/README.md b/README.md index 6ca4cfb..ac6b4e8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,37 @@ # my2dos +Kleine Django/HTMX/Alpine.js-App für Aufgaben, Notizen und Links. + +## Setup + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python manage.py migrate +python manage.py createsuperuser +python manage.py runserver +``` + +Login aktuell über `/admin/login/`, danach App unter `/`. + +## Schnell-Erfassung + +- `/todo` erstellt eine Aufgabe +- `/note` erstellt eine Notiz (Default) +- `/link` erstellt einen Link-Eintrag +- `/public` macht den Eintrag öffentlich, sonst privat +- `#tag` gruppiert Einträge nach Tags +- `[[note:1]]`, `[[todo:2]]` oder `[[3]]` verlinkt intern auf andere Einträge +- Markdown wird gerendert +- Anhänge können beim Erstellen hochgeladen werden + +## API + +Authentifiziert per Django-Session: + +- `GET /api/items/` +- `POST /api/items/` JSON z.B. `{ "content": "/todo Text #tag /public" }` +- `GET /api/items//` +- `PATCH /api/items//` +- `DELETE /api/items//` diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 0000000..251f357 --- /dev/null +++ b/core/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin +from .models import ApiKey, Attachment, Item, Tag + + +class AttachmentInline(admin.TabularInline): + model = Attachment + extra = 0 + + +@admin.register(Item) +class ItemAdmin(admin.ModelAdmin): + list_display = ('id', 'kind', 'visibility', 'is_done', 'owner', 'created_at') + list_filter = ('kind', 'visibility', 'is_done', 'tags') + search_fields = ('content', 'url') + inlines = [AttachmentInline] + + +@admin.register(ApiKey) +class ApiKeyAdmin(admin.ModelAdmin): + list_display = ('name', 'owner', 'is_active', 'created_at') + filter_horizontal = ('tags',) + + +admin.site.register(Tag) diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 0000000..8115ae6 --- /dev/null +++ b/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..ab68d3b --- /dev/null +++ b/core/forms.py @@ -0,0 +1,60 @@ +from django import forms +from django.contrib.auth.models import User +from .models import ApiKey, Attachment, Item + + +class QuickItemForm(forms.ModelForm): + files = forms.FileField(required=False, widget=forms.ClearableFileInput(attrs={'multiple': False})) + + class Meta: + model = Item + fields = ['content'] + widgets = {'content': forms.Textarea(attrs={ + 'class': 'form-control form-control-lg', + 'rows': 3, + 'placeholder': 'Schreibe etwas … /todo /link /public #tag [[note:1]]', + 'x-model': 'text', + '@keydown': 'onKeydown($event)', + '@input': 'onInput($event)', + '@paste': 'handlePaste($event)', + })} + + +class EditItemForm(forms.ModelForm): + files = forms.FileField(required=False, widget=forms.ClearableFileInput()) + + class Meta: + model = Item + fields = ['content', 'comment', 'visibility', 'due_at', 'url'] + widgets = { + 'comment': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + 'due_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'), + 'url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'URL'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['due_at'].input_formats = ['%Y-%m-%dT%H:%M'] + + +class UserSettingsForm(forms.ModelForm): + class Meta: + model = User + fields = ['first_name', 'last_name', 'email'] + widgets = {field: forms.TextInput(attrs={'class': 'form-control'}) for field in ['first_name', 'last_name', 'email']} + + +class ApiKeyForm(forms.ModelForm): + class Meta: + model = ApiKey + fields = ['name', 'tags'] + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Name des API Keys'}), + 'tags': forms.CheckboxSelectMultiple(), + } + + +class AttachmentForm(forms.ModelForm): + class Meta: + model = Attachment + fields = ['file'] diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..ac30279 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated for my2dos MVP +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [ + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.SlugField(allow_unicode=True, max_length=80, unique=True)), + ], + options={'ordering': ['name']}, + ), + migrations.CreateModel( + name='Item', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('todo', 'Aufgabe'), ('note', 'Notiz'), ('link', 'Link')], default='note', max_length=10)), + ('content', models.TextField()), + ('visibility', models.CharField(choices=[('private', 'Privat'), ('public', 'Öffentlich')], default='private', max_length=10)), + ('is_done', models.BooleanField(default=False)), + ('url', models.URLField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('linked_items', models.ManyToManyField(blank=True, related_name='backlinks', to='core.item')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to=settings.AUTH_USER_MODEL)), + ('tags', models.ManyToManyField(blank=True, related_name='items', to='core.tag')), + ], + options={'ordering': ['-created_at']}, + ), + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='attachments/%Y/%m/')), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='core.item')), + ], + ), + ] diff --git a/core/migrations/0002_item_due_at.py b/core/migrations/0002_item_due_at.py new file mode 100644 index 0000000..30c34db --- /dev/null +++ b/core/migrations/0002_item_due_at.py @@ -0,0 +1,12 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [('core', '0001_initial')] + operations = [ + migrations.AddField( + model_name='item', + name='due_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/core/migrations/0003_apikey.py b/core/migrations/0003_apikey.py new file mode 100644 index 0000000..6934edb --- /dev/null +++ b/core/migrations/0003_apikey.py @@ -0,0 +1,22 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [('core', '0002_item_due_at'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [ + migrations.CreateModel( + name='ApiKey', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='API Key', max_length=120)), + ('token', models.CharField(editable=False, max_length=80, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('is_active', models.BooleanField(default=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)), + ('tags', models.ManyToManyField(blank=True, related_name='api_keys', to='core.tag')), + ], + options={'ordering': ['-created_at']}, + ), + ] diff --git a/core/migrations/0004_item_comment.py b/core/migrations/0004_item_comment.py new file mode 100644 index 0000000..91562dd --- /dev/null +++ b/core/migrations/0004_item_comment.py @@ -0,0 +1,12 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [('core', '0003_apikey')] + operations = [ + migrations.AddField( + model_name='item', + name='comment', + field=models.TextField(blank=True), + ), + ] diff --git a/core/migrations/__init__.py b/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..23049d2 --- /dev/null +++ b/core/models.py @@ -0,0 +1,87 @@ +import re +import secrets +from django.conf import settings +from django.db import models +from django.urls import reverse + +TAG_RE = re.compile(r'(? + {% csrf_token %} +
+
Typ: {{ item.get_kind_display }}
+ + +
+ +
+
+
+ {% if item.kind == 'todo' %}
{% endif %} +
+
+ {% if item.kind == 'link' %}{% endif %} + +
+ + +
+
+ diff --git a/core/templates/core/_item.html b/core/templates/core/_item.html new file mode 100644 index 0000000..422a363 --- /dev/null +++ b/core/templates/core/_item.html @@ -0,0 +1,31 @@ +{% load markdown_extras %} +
+
+
+
+
+ {{ item.get_kind_display }} + {{ item.get_visibility_display }} + #{{ item.id }} + {% for tag in item.tags.all %}#{{ tag.name }}{% endfor %} +
+
{{ item.content|markdown }}
+ {% if item.comment %}
{{ item.comment|markdown }}
{% endif %} + {% if item.due_at %}
Fällig: {{ item.due_at|date:'d.m.Y H:i' }}
{% endif %} + {% if item.url %}{{ item.url }}{% endif %} + {% if item.linked_items.all %}
Links: {% for linked in item.linked_items.all %}#{{ linked.id }} {% endfor %}
{% endif %} + {% if item.attachments.all %} +
Anhänge: {% for a in item.attachments.all %}{{ a.file.name }} {% endfor %}
+
+ {% for a in item.attachments.all %}{% if a.file.name|is_image %}{{ a.file.name }}{% endif %}{% endfor %} +
+ {% endif %} +
+
+ {% if item.kind == 'todo' %}{% endif %} + + +
+
+
+
diff --git a/core/templates/core/_tags.html b/core/templates/core/_tags.html new file mode 100644 index 0000000..7cfe232 --- /dev/null +++ b/core/templates/core/_tags.html @@ -0,0 +1,10 @@ +{% load querystring %} +
+ {% for tag in tags %} + {% if active_tag == tag.name %} + #{{ tag.name }} × + {% else %} + #{{ tag.name }} + {% endif %} + {% empty %}Noch keine Tags{% endfor %} +
diff --git a/core/templates/core/base.html b/core/templates/core/base.html new file mode 100644 index 0000000..c952d12 --- /dev/null +++ b/core/templates/core/base.html @@ -0,0 +1,37 @@ +{% load static %} + + + + + + my2dos + + + + + + + +
{% for message in messages %}
{{ message }}
{% endfor %}{% block content %}{% endblock %}
+ + + diff --git a/core/templates/core/detail.html b/core/templates/core/detail.html new file mode 100644 index 0000000..4d9a5c0 --- /dev/null +++ b/core/templates/core/detail.html @@ -0,0 +1,8 @@ +{% extends 'core/base.html' %} +{% block content %} +← zurück +{% include 'core/_item.html' %} +{% if item.backlinks.all %} +
Backlinks
{% for item in item.backlinks.all %}{% include 'core/_item.html' %}{% endfor %}
+{% endif %} +{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html new file mode 100644 index 0000000..d05ced3 --- /dev/null +++ b/core/templates/core/index.html @@ -0,0 +1,170 @@ +{% extends 'core/base.html' %} +{% load querystring %} +{% block content %} +
+ +
+
+ {% csrf_token %} + {{ form.content }} +
+ +
+
+ + +
+ +
+
+ {% for item in items %}{% include 'core/_item.html' %}{% empty %}
Noch nichts vorhanden.
{% endfor %} +
+
+
+ +{% endblock %} diff --git a/core/templates/core/login.html b/core/templates/core/login.html new file mode 100644 index 0000000..9248fd5 --- /dev/null +++ b/core/templates/core/login.html @@ -0,0 +1,15 @@ +{% extends 'core/base.html' %} +{% block content %} +
+
+

Anmelden

+ {% if form.errors %}
Benutzername oder Passwort ist falsch.
{% endif %} +
+ {% csrf_token %} +
+
+ +
+
+
+{% endblock %} diff --git a/core/templates/core/settings.html b/core/templates/core/settings.html new file mode 100644 index 0000000..45de873 --- /dev/null +++ b/core/templates/core/settings.html @@ -0,0 +1,41 @@ +{% extends 'core/base.html' %} +{% block content %} +← zurück +
+
+
+

Benutzereinstellungen

+
+ {% csrf_token %} +
{{ profile_form.first_name }}
+
{{ profile_form.last_name }}
+
{{ profile_form.email }}
+ +
+
+
+
+
+

API Key erzeugen

+
+ {% csrf_token %} +
{{ key_form.name }}
+
Keine Auswahl = Zugriff auf alle eigenen Einträge.
{{ key_form.tags }}
+ +
+
+
+

API Keys

+
+ {% for key in api_keys %} +
+
{{ key.name }}
{% csrf_token %}
+ {{ key.token }} +
Tags: {% for tag in key.tags.all %}#{{ tag.name }} {% empty %}alle{% endfor %}
+
+ {% empty %}Noch keine API Keys.{% endfor %} +
+
+
+
+{% endblock %} diff --git a/core/templatetags/__init__.py b/core/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/templatetags/markdown_extras.py b/core/templatetags/markdown_extras.py new file mode 100644 index 0000000..c9507ac --- /dev/null +++ b/core/templatetags/markdown_extras.py @@ -0,0 +1,23 @@ +import bleach +import markdown as md +from django import template +from django.utils.safestring import mark_safe + +register = template.Library() + +ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | { + 'p', 'pre', 'code', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', + 'strong', 'em', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br' +} +ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class']} + + +@register.filter +def markdown(text): + html = md.markdown(text or '', extensions=['extra', 'sane_lists', 'nl2br']) + return mark_safe(bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS)) + + +@register.filter +def is_image(file_name): + return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg')) diff --git a/core/templatetags/querystring.py b/core/templatetags/querystring.py new file mode 100644 index 0000000..ecf0b81 --- /dev/null +++ b/core/templatetags/querystring.py @@ -0,0 +1,15 @@ +from django import template + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def query_replace(context, **kwargs): + query = context['request'].GET.copy() + for key, value in kwargs.items(): + if value is None or value == '': + query.pop(key, None) + else: + query[key] = value + encoded = query.urlencode() + return f'?{encoded}' if encoded else '?' diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..7572b51 --- /dev/null +++ b/core/urls.py @@ -0,0 +1,21 @@ +from django.contrib.auth import views as auth_views +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.index, name='index'), + path('login/', auth_views.LoginView.as_view(template_name='core/login.html'), name='login'), + path('logout/', auth_views.LogoutView.as_view(), name='logout'), + path('settings/', views.settings_view, name='settings'), + path('settings/api-keys//delete/', views.delete_api_key, name='delete_api_key'), + path('items//', views.item_detail, name='item_detail'), + path('items//card/', views.item_card, name='item_card'), + path('items//edit/', views.edit_item, name='edit_item'), + path('items//toggle/', views.toggle_done, name='toggle_done'), + path('items//delete/', views.delete_item, name='delete_item'), + path('tags/', views.tag_list, name='tag_list'), + path('api/tags/', views.api_tags, name='api_tags'), + path('api/item-suggestions/', views.api_item_suggestions, name='api_item_suggestions'), + path('api/items/', views.api_items, name='api_items'), + path('api/items//', views.api_item_detail, name='api_item_detail'), +] diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000..b3bfda0 --- /dev/null +++ b/core/views.py @@ -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']*>(.*?)', 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)) diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..df830ba --- /dev/null +++ b/manage.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'my2dos.settings') + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/my2dos/__init__.py b/my2dos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/my2dos/settings.py b/my2dos/settings.py new file mode 100644 index 0000000..79b2d75 --- /dev/null +++ b/my2dos/settings.py @@ -0,0 +1,55 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent +SECRET_KEY = 'dev-only-change-me' +DEBUG = True +ALLOWED_HOSTS = ['*'] + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'core', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'my2dos.urls' +TEMPLATES = [{ + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': {'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ]}, +}] +WSGI_APPLICATION = 'my2dos.wsgi.application' + +DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3'}} + +AUTH_PASSWORD_VALIDATORS = [] +LANGUAGE_CODE = 'de-de' +TIME_ZONE = 'Europe/Berlin' +USE_I18N = True +USE_TZ = True +STATIC_URL = 'static/' +STATICFILES_DIRS = [BASE_DIR / 'static'] if (BASE_DIR / 'static').exists() else [] +MEDIA_URL = 'media/' +MEDIA_ROOT = BASE_DIR / 'media' +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +LOGIN_URL = '/login/' +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/login/' diff --git a/my2dos/urls.py b/my2dos/urls.py new file mode 100644 index 0000000..3d8a6b1 --- /dev/null +++ b/my2dos/urls.py @@ -0,0 +1,12 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('core.urls')), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/my2dos/wsgi.py b/my2dos/wsgi.py new file mode 100644 index 0000000..c3d84e4 --- /dev/null +++ b/my2dos/wsgi.py @@ -0,0 +1,5 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'my2dos.settings') +application = get_wsgi_application() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..109f951 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Django>=5.0,<6.0 +Markdown>=3.6 +bleach>=6.1