From 40648d10a131ef1905573d337fd32b779e0542aa Mon Sep 17 00:00:00 2001 From: rucki Date: Sat, 22 Aug 2026 16:26:46 +0200 Subject: [PATCH] Add kanban updates and static root --- core/admin.py | 3 +- core/forms.py | 17 ++- .../0008_item_completed_at_kanban.py | 25 ++++ core/migrations/0009_item_kanban_status.py | 12 ++ core/models.py | 23 +++- core/templates/core/_kanban_card.html | 3 + core/templates/core/base.html | 3 +- core/templates/core/index.html | 9 -- core/templates/core/journal.html | 47 +++++--- core/templates/core/kanban_detail.html | 14 +++ core/templates/core/kanban_list.html | 8 ++ core/urls.py | 3 + core/views.py | 109 +++++++++++++----- my2dos/settings.py | 3 +- 14 files changed, 220 insertions(+), 59 deletions(-) create mode 100644 core/migrations/0008_item_completed_at_kanban.py create mode 100644 core/migrations/0009_item_kanban_status.py create mode 100644 core/templates/core/_kanban_card.html create mode 100644 core/templates/core/kanban_detail.html create mode 100644 core/templates/core/kanban_list.html diff --git a/core/admin.py b/core/admin.py index 4c12be2..f54db88 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import ApiKey, Attachment, Item, Tag, UserPreference +from .models import ApiKey, Attachment, Item, Kanban, Tag, UserPreference class AttachmentInline(admin.TabularInline): @@ -22,4 +22,5 @@ class ApiKeyAdmin(admin.ModelAdmin): admin.site.register(Tag) +admin.site.register(Kanban) admin.site.register(UserPreference) diff --git a/core/forms.py b/core/forms.py index f65a482..74fb69e 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,6 +1,6 @@ from django import forms from django.contrib.auth.models import User -from .models import ApiKey, Attachment, Item, UserPreference +from .models import ApiKey, Attachment, Item, Kanban, Tag, UserPreference class MultipleFileInput(forms.ClearableFileInput): @@ -71,6 +71,21 @@ class UserPreferenceForm(forms.ModelForm): widgets = {'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50})} +class KanbanForm(forms.ModelForm): + tag_name = forms.CharField(label='Tag', widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'})) + + class Meta: + model = Kanban + fields = ['name', 'tag_name'] + widgets = {'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Name des Kanbans'})} + + def save(self, commit=True): + tag_name = self.cleaned_data['tag_name'].strip().lstrip('#').lower() + tag, _ = Tag.objects.get_or_create(name=tag_name) + self.instance.tag = tag + return super().save(commit=commit) + + class ApiKeyForm(forms.ModelForm): class Meta: model = ApiKey diff --git a/core/migrations/0008_item_completed_at_kanban.py b/core/migrations/0008_item_completed_at_kanban.py new file mode 100644 index 0000000..d9ab534 --- /dev/null +++ b/core/migrations/0008_item_completed_at_kanban.py @@ -0,0 +1,25 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [('core', '0007_userpreference_overview_lines'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [ + migrations.AddField( + model_name='item', + name='completed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.CreateModel( + name='Kanban', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kanbans', to=settings.AUTH_USER_MODEL)), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kanbans', to='core.tag')), + ], + options={'ordering': ['name'], 'unique_together': {('owner', 'tag')}}, + ), + ] diff --git a/core/migrations/0009_item_kanban_status.py b/core/migrations/0009_item_kanban_status.py new file mode 100644 index 0000000..51e0125 --- /dev/null +++ b/core/migrations/0009_item_kanban_status.py @@ -0,0 +1,12 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [('core', '0008_item_completed_at_kanban')] + operations = [ + migrations.AddField( + model_name='item', + name='kanban_status', + field=models.CharField(choices=[('inbox', 'Eingang'), ('todo', 'Todo'), ('progress', 'In Progress'), ('done', 'Erledigt')], default='inbox', max_length=20), + ), + ] diff --git a/core/models.py b/core/models.py index f56b768..b8b2a77 100644 --- a/core/models.py +++ b/core/models.py @@ -5,7 +5,7 @@ from django.db import models from django.urls import reverse TAG_RE = re.compile(r'(? +
#{{ item.id }} {{ item.content|truncatechars:120 }}
+ diff --git a/core/templates/core/base.html b/core/templates/core/base.html index 85ec697..d8f6fa4 100644 --- a/core/templates/core/base.html +++ b/core/templates/core/base.html @@ -7,13 +7,14 @@ my2dos + - +
{% for message in messages %}
{{ message }}
{% endfor %}{% block content %}{% endblock %}
+{% endblock %} diff --git a/core/templates/core/kanban_list.html b/core/templates/core/kanban_list.html new file mode 100644 index 0000000..5cbeffd --- /dev/null +++ b/core/templates/core/kanban_list.html @@ -0,0 +1,8 @@ +{% extends 'core/base.html' %} +{% block content %} +

Kanbans

← zurück
+
+

Neues Kanban

{% csrf_token %}{{ form.name }}{{ form.tag_name }}
+
{% for s in stats %}
{{ s.kanban.name }}#{{ s.kanban.tag.name }}
Offen: {{ s.open }} · Erledigt: {{ s.done }}
{% empty %}
Noch keine Kanbans.
{% endfor %}
+
+{% endblock %} diff --git a/core/urls.py b/core/urls.py index c2e686a..f5a9a68 100644 --- a/core/urls.py +++ b/core/urls.py @@ -8,6 +8,9 @@ urlpatterns = [ path('logout/', auth_views.LogoutView.as_view(), name='logout'), path('new/', views.new_item, name='new_item'), path('journal/', views.journal, name='journal'), + path('kanban/', views.kanban_list, name='kanban_list'), + path('kanban//', views.kanban_detail, name='kanban_detail'), + path('kanban//move//', views.kanban_move, name='kanban_move'), 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'), diff --git a/core/views.py b/core/views.py index eb59cb8..2c1788d 100644 --- a/core/views.py +++ b/core/views.py @@ -9,11 +9,12 @@ 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.utils import timezone 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, UserPreferenceForm, UserSettingsForm -from .models import ApiKey, Attachment, Item, Tag, UserPreference +from .forms import ApiKeyForm, EditItemForm, KanbanForm, QuickItemForm, UserPreferenceForm, UserSettingsForm +from .models import ApiKey, Attachment, Item, Kanban, Tag, UserPreference COMMAND_RE = re.compile(r'/(todo|note|link|journal|public|private)\b', re.I) URL_RE = re.compile(r'https?://\S+') @@ -154,19 +155,8 @@ def index(request): if q: items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct() prefs, _ = UserPreference.objects.get_or_create(user=request.user) - journal_days = [] - if kind == Item.Kind.JOURNAL: - today = date.today() - year = int(request.GET.get('year', today.year)) - month = int(request.GET.get('month', today.month)) - journal_qs = visible_items(request.user).filter(kind=Item.Kind.JOURNAL, created_at__year=year, created_at__month=month) - days_with_entries = set(journal_qs.dates('created_at', 'day')) - journal_days = [ - {'day': d, 'date': date(year, month, d).isoformat(), 'has_entries': date(year, month, d) in days_with_entries} - for week in Calendar(firstweekday=0).monthdayscalendar(year, month) for d in week if d - ] return render(request, 'core/index.html', { - 'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'active_tag': tag, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'journal_days': journal_days, 'overview_lines': prefs.overview_lines, + 'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'active_tag': tag, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'overview_lines': prefs.overview_lines, }) @@ -185,21 +175,39 @@ def new_item(request): @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 re.search(r'/(todo|note|link)\b', raw, re.I): + raw = f'/journal {raw}' + kind, visibility, content, url = parse_quick_content(raw) + item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url) + attach_files_and_replace_tokens(item, request.FILES.getlist('files')) + item.sync_metadata() + 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() - year = int(request.GET.get('year', today.year)) - month = int(request.GET.get('month', today.month)) - entries = visible_items(request.user).filter(kind=Item.Kind.JOURNAL, created_at__year=year, created_at__month=month) - by_day = {} - for item in entries: - by_day.setdefault(item.created_at.day, []).append(item) - weeks = [] - for week in Calendar(firstweekday=0).monthdayscalendar(year, month): - weeks.append([(day, by_day.get(day, [])) for day in week]) + 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, _ = UserPreference.objects.get_or_create(user=request.user) return render(request, 'core/journal.html', { - 'weeks': weeks, 'year': year, 'month': month, - 'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month, + '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, }) @@ -256,7 +264,8 @@ def item_detail(request, pk): 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']) + 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}) @@ -305,6 +314,51 @@ def delete_item(request, pk): 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): return render(request, 'core/_tags.html', {'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')}) @@ -340,6 +394,7 @@ def serialize_item(item): 'comment': item.comment, 'visibility': item.visibility, '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()], @@ -384,6 +439,8 @@ def api_item_detail(request, pk): for field in ['content', 'comment', 'kind', 'visibility', 'is_done', 'due_at', 'url']: if field in data: setattr(item, field, data[field]) + 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() return JsonResponse(serialize_item(item)) diff --git a/my2dos/settings.py b/my2dos/settings.py index 618af12..bddaf26 100644 --- a/my2dos/settings.py +++ b/my2dos/settings.py @@ -8,7 +8,7 @@ DEBUG = os.environ.get('DJANGO_DEBUG', 'true').lower() in {'1', 'true', 'yes', ' ALLOWED_HOSTS = [host.strip() for host in os.environ.get( 'DJANGO_ALLOWED_HOSTS', - 'localhost,127.0.0.1,my2dos.rucki.ch', + 'localhost,127.0.0.1,testserver,my2dos.rucki.ch', ).split(',') if host.strip()] INSTALLED_APPS = [ @@ -52,6 +52,7 @@ TIME_ZONE = 'Europe/Berlin' USE_I18N = True USE_TZ = True STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' STATICFILES_DIRS = [BASE_DIR / 'static'] if (BASE_DIR / 'static').exists() else [] MEDIA_URL = 'media/' MEDIA_ROOT = BASE_DIR / 'media'