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 %}
+
+
+{% 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'