Add kanban updates and static root

This commit is contained in:
rucki
2026-08-22 16:26:46 +02:00
parent b7b5e19a90
commit 40648d10a1
14 changed files with 220 additions and 59 deletions
+2 -1
View File
@@ -1,5 +1,5 @@
from django.contrib import admin 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): class AttachmentInline(admin.TabularInline):
@@ -22,4 +22,5 @@ class ApiKeyAdmin(admin.ModelAdmin):
admin.site.register(Tag) admin.site.register(Tag)
admin.site.register(Kanban)
admin.site.register(UserPreference) admin.site.register(UserPreference)
+16 -1
View File
@@ -1,6 +1,6 @@
from django import forms from django import forms
from django.contrib.auth.models import User 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): 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})} 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 ApiKeyForm(forms.ModelForm):
class Meta: class Meta:
model = ApiKey model = ApiKey
@@ -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')}},
),
]
@@ -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),
),
]
+22 -1
View File
@@ -5,7 +5,7 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
TAG_RE = re.compile(r'(?<!\w)#([\wäöüÄÖÜß-]+)') TAG_RE = re.compile(r'(?<!\w)#([\wäöüÄÖÜß-]+)')
LINK_RE = re.compile(r'\[\[(todo|note|link)?:?(\d+)\]\]', re.I) LINK_RE = re.compile(r'\[\[(todo|note|link|journal)?:?(\d+)\]\]', re.I)
class Tag(models.Model): class Tag(models.Model):
@@ -34,7 +34,14 @@ class Item(models.Model):
content = models.TextField() content = models.TextField()
comment = models.TextField(blank=True) comment = models.TextField(blank=True)
visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE) visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE)
kanban_status = models.CharField(max_length=20, default='inbox', choices=[
('inbox', 'Eingang'),
('todo', 'Todo'),
('progress', 'In Progress'),
('done', 'Erledigt'),
])
is_done = models.BooleanField(default=False) is_done = models.BooleanField(default=False)
completed_at = models.DateTimeField(blank=True, null=True)
due_at = models.DateTimeField(blank=True, null=True) due_at = models.DateTimeField(blank=True, null=True)
url = models.URLField(blank=True) url = models.URLField(blank=True)
tags = models.ManyToManyField(Tag, blank=True, related_name='items') tags = models.ManyToManyField(Tag, blank=True, related_name='items')
@@ -59,6 +66,20 @@ class Item(models.Model):
self.linked_items.set(Item.objects.filter(id__in=ids).exclude(id=self.id)) self.linked_items.set(Item.objects.filter(id__in=ids).exclude(id=self.id))
class Kanban(models.Model):
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='kanbans')
name = models.CharField(max_length=120)
tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name='kanbans')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
unique_together = [('owner', 'tag')]
def __str__(self):
return self.name
class UserPreference(models.Model): class UserPreference(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences') user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences')
show_notes = models.BooleanField(default=True) show_notes = models.BooleanField(default=True)
+3
View File
@@ -0,0 +1,3 @@
<div class="card {% if item.is_done %}done{% endif %}" draggable="true" data-id="{{ item.id }}" @dragstart="drag($event)">
<div class="card-body py-2 small"><a href="{{ item.get_absolute_url }}">#{{ item.id }}</a> {{ item.content|truncatechars:120 }}</div>
</div>
+2 -1
View File
@@ -7,13 +7,14 @@
<title>my2dos</title> <title>my2dos</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@1.9.12"></script> <script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style> <style>
body{background:#f7f7f4}.shell{max-width:960px}.item-card{border:0;border-radius:1rem}.content p:last-child{margin-bottom:0}.tag{font-size:.85rem}.slash-menu{position:absolute;z-index:20;top:100%;left:0;width:18rem}.done{opacity:.6}.done .content{text-decoration:line-through}.content img{max-width:100%;max-height:420px;border-radius:.5rem;border:1px solid #ddd;padding:.25rem;background:white}.overview-content{max-height:calc(var(--overview-lines) * 1.55em);overflow:hidden}.overview-content.expanded{max-height:none}.content{min-width:0;max-width:100%;overflow-wrap:anywhere}.content pre{position:relative;display:block;width:100%;max-width:100%;box-sizing:border-box;background:#1f2937;color:#f9fafb;border-radius:.6rem;padding:1rem;overflow-x:auto;overflow-y:auto;white-space:pre}.content pre code{display:block;color:inherit;white-space:pre;min-width:0}.copy-code{position:absolute;top:.4rem;right:.4rem;line-height:1} body{background:#f7f7f4}.shell{max-width:960px}.item-card{border:0;border-radius:1rem}.content p:last-child{margin-bottom:0}.tag{font-size:.85rem}.slash-menu{position:absolute;z-index:20;top:100%;left:0;width:18rem}.done{opacity:.6}.done .content{text-decoration:line-through}.content img{max-width:100%;max-height:420px;border-radius:.5rem;border:1px solid #ddd;padding:.25rem;background:white}.overview-content{max-height:calc(var(--overview-lines) * 1.55em);overflow:hidden}.overview-content.expanded{max-height:none}.content{min-width:0;max-width:100%;overflow-wrap:anywhere}.content pre{position:relative;display:block;width:100%;max-width:100%;box-sizing:border-box;background:#1f2937;color:#f9fafb;border-radius:.6rem;padding:1rem;overflow-x:auto;overflow-y:auto;white-space:pre}.content pre code{display:block;color:inherit;white-space:pre;min-width:0}.copy-code{position:absolute;top:.4rem;right:.4rem;line-height:1}
</style> </style>
</head> </head>
<body> <body>
<nav class="navbar bg-white border-bottom sticky-top"><div class="container shell"><a class="navbar-brand fw-bold" href="/">my2dos</a><div class="d-flex align-items-center gap-3"><span class="text-muted small d-none d-sm-inline">Notizen · Aufgaben · Links</span>{% if user.is_authenticated %}<a class="small" href="{% url 'journal' %}">Journal</a><a class="small" href="{% url 'settings' %}">{{ user.get_full_name|default:user.username }}</a><form method="post" action="{% url 'logout' %}" class="m-0">{% csrf_token %}<button class="btn btn-sm btn-outline-secondary">Logout</button></form>{% else %}<a class="btn btn-sm btn-dark" href="{% url 'login' %}">Login</a>{% endif %}</div></div></nav> <nav class="navbar bg-white border-bottom sticky-top"><div class="container shell"><a class="navbar-brand fw-bold" href="/">my2dos</a><div class="d-flex align-items-center gap-3"><a class="text-muted small d-none d-sm-inline text-decoration-none" href="/">Notizen · Aufgaben · Links</a>{% if user.is_authenticated %}<a class="small" href="{% url 'journal' %}">Journal</a><a class="small" href="{% url 'kanban_list' %}">Kanban</a><div class="dropdown"><button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">{{ user.get_full_name|default:user.username }}</button><ul class="dropdown-menu dropdown-menu-end"><li><a class="dropdown-item" href="{% url 'settings' %}">Einstellungen</a></li>{% if user.is_staff %}<li><a class="dropdown-item" href="/admin/">Admin</a></li>{% endif %}<li><hr class="dropdown-divider"></li><li><form method="post" action="{% url 'logout' %}">{% csrf_token %}<button class="dropdown-item">Logout</button></form></li></ul></div>{% else %}<a class="btn btn-sm btn-dark" href="{% url 'login' %}">Login</a>{% endif %}</div></div></nav>
<main class="container shell py-4">{% for message in messages %}<div class="alert alert-{{ message.tags|default:'info' }}">{{ message }}</div>{% endfor %}{% block content %}{% endblock %}</main> <main class="container shell py-4">{% for message in messages %}<div class="alert alert-{{ message.tags|default:'info' }}">{{ message }}</div>{% endfor %}{% block content %}{% endblock %}</main>
<script> <script>
document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'}); document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'});
-9
View File
@@ -9,7 +9,6 @@
<a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% if active_kind == 'todo' %}{% query_replace kind=None %}{% else %}{% query_replace kind='todo' %}{% endif %}">Todos{% if active_kind == 'todo' %} ×{% endif %}</a> <a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% if active_kind == 'todo' %}{% query_replace kind=None %}{% else %}{% query_replace kind='todo' %}{% endif %}">Todos{% if active_kind == 'todo' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">Notizen{% if active_kind == 'note' %} ×{% endif %}</a> <a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">Notizen{% if active_kind == 'note' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_kind == 'link' %}btn-success{% else %}btn-outline-success{% endif %}" href="{% if active_kind == 'link' %}{% query_replace kind=None %}{% else %}{% query_replace kind='link' %}{% endif %}">Links{% if active_kind == 'link' %} ×{% endif %}</a> <a class="btn btn-sm {% if active_kind == 'link' %}btn-success{% else %}btn-outline-success{% endif %}" href="{% if active_kind == 'link' %}{% query_replace kind=None %}{% else %}{% query_replace kind='link' %}{% endif %}">Links{% if active_kind == 'link' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_kind == 'journal' %}btn-info{% else %}btn-outline-info{% endif %}" href="{% if active_kind == 'journal' %}{% query_replace kind=None day=None %}{% else %}{% query_replace kind='journal' %}{% endif %}">Journal{% if active_kind == 'journal' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_status == 'open' %}btn-warning{% else %}btn-outline-warning{% endif %}" href="{% if active_status == 'open' %}{% query_replace status=None %}{% else %}{% query_replace status='open' %}{% endif %}">Unerledigt{% if active_status == 'open' %} ×{% endif %}</a> <a class="btn btn-sm {% if active_status == 'open' %}btn-warning{% else %}btn-outline-warning{% endif %}" href="{% if active_status == 'open' %}{% query_replace status=None %}{% else %}{% query_replace status='open' %}{% endif %}">Unerledigt{% if active_status == 'open' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_status == 'archive' %}btn-dark{% else %}btn-outline-dark{% endif %}" href="{% if active_status == 'archive' %}{% query_replace status=None %}{% else %}{% query_replace status='archive' %}{% endif %}">Archiv{% if active_status == 'archive' %} ×{% endif %}</a> <a class="btn btn-sm {% if active_status == 'archive' %}btn-dark{% else %}btn-outline-dark{% endif %}" href="{% if active_status == 'archive' %}{% query_replace status=None %}{% else %}{% query_replace status='archive' %}{% endif %}">Archiv{% if active_status == 'archive' %} ×{% endif %}</a>
</div> </div>
@@ -25,14 +24,6 @@
<template x-for="(tag, idx) in filtered" :key="tag"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="select(tag)"><strong x-text="'#'+tag"></strong></button></template> <template x-for="(tag, idx) in filtered" :key="tag"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="select(tag)"><strong x-text="'#'+tag"></strong></button></template>
</div> </div>
</form> </form>
{% if active_kind == 'journal' and journal_days %}
<div class="fw-semibold mb-2">Journal-Kalender</div>
<div class="d-flex flex-wrap gap-1 mb-3">
{% for d in journal_days %}
{% if d.has_entries %}<a class="btn btn-sm {% if active_day == d.date %}btn-info{% else %}btn-outline-info{% endif %}" href="{% query_replace day=d.date %}">{{ d.day }}</a>{% else %}<span class="btn btn-sm btn-light disabled">{{ d.day }}</span>{% endif %}
{% endfor %}
</div>
{% endif %}
<div class="fw-semibold mb-2">Tags</div> <div class="fw-semibold mb-2">Tags</div>
<div hx-get="{% url 'tag_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-trigger="tagsChanged from:body" hx-target="#tag-list" hx-swap="outerHTML"> <div hx-get="{% url 'tag_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-trigger="tagsChanged from:body" hx-target="#tag-list" hx-swap="outerHTML">
{% include 'core/_tags.html' %} {% include 'core/_tags.html' %}
+27 -18
View File
@@ -1,23 +1,32 @@
{% extends 'core/base.html' %} {% extends 'core/base.html' %}
{% load markdown_extras %} {% load querystring %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="row g-4">
<a class="btn btn-sm btn-outline-secondary" href="/">← zurück</a> <aside class="col-lg-3 order-lg-2">
<h1 class="h4 m-0">Journal {{ month }}/{{ year }}</h1> <div class="card item-card shadow-sm"><div class="card-body">
<div class="btn-group btn-group-sm"> <div class="d-flex justify-content-between align-items-center mb-2">
<a class="btn btn-outline-dark" href="?year={{ prev_year }}&month={{ prev_month }}">←</a> <a class="btn btn-sm btn-outline-dark" href="?year={{ prev_year }}&month={{ prev_month }}">←</a>
<a class="btn btn-outline-dark" href="?year={{ next_year }}&month={{ next_month }}">→</a> <strong>{{ month }}/{{ year }}</strong>
<a class="btn btn-sm btn-outline-dark" href="?year={{ next_year }}&month={{ next_month }}">→</a>
</div> </div>
</div> <table class="table table-sm text-center mb-3"><thead><tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr></thead><tbody>
<div class="card item-card shadow-sm"><div class="card-body table-responsive"> {% for week in weeks %}<tr>{% for d in week %}<td>{% if d.day %}{% if d.has_entries %}<a class="btn btn-sm {% if selected_day == d.date %}btn-info{% else %}btn-outline-info{% endif %}" href="?day={{ d.date }}&year={{ year }}&month={{ month }}">{{ d.day }}</a>{% else %}<span class="small text-muted">{{ d.day }}</span>{% endif %}{% endif %}</td>{% endfor %}</tr>{% endfor %}
<table class="table table-bordered align-top mb-0"> </tbody></table>
<thead><tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr></thead> <div class="fw-semibold mb-2">Tags</div>
<tbody> {% include 'core/_tags.html' %}
{% for week in weeks %}<tr>{% for day, entries in week %}<td style="height:120px;width:14%" {% if not day %}class="bg-light"{% endif %}>
{% if day %}<div class="fw-semibold small mb-1">{{ day }}</div>{% endif %}
{% for item in entries %}<a class="d-block small text-decoration-none mb-1" href="{{ item.get_absolute_url }}">{{ item.content|truncatechars:55 }}</a>{% endfor %}
</td>{% endfor %}</tr>{% endfor %}
</tbody>
</table>
</div></div> </div></div>
</aside>
<section class="col-lg-9">
<h1 class="h4 mb-3">Journal {{ selected_day }}</h1>
<form class="position-relative mb-4" method="post" enctype="multipart/form-data" hx-post="{{ request.get_full_path }}" hx-target="#items" hx-swap="afterbegin" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:htmx:after-request="if($event.detail.successful){text='';open=false;$el.reset()}">
{% csrf_token %}
{{ form.content }}
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak><template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template></div>
<div class="d-flex align-items-center gap-2 mt-2"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"><button class="btn btn-dark px-4">Speichern</button></div>
</form>
<div id="items" class="vstack gap-3">
{% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-5">Keine Journal-Aktivität an diesem Tag.</div>{% endfor %}
</div>
</section>
</div>
{% endblock %} {% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends 'core/base.html' %}
{% block content %}
<a class="btn btn-sm btn-outline-secondary mb-3" href="{% url 'kanban_list' %}">← Kanbans</a>
<h1 class="h4 mb-3">{{ kanban.name }} <span class="badge text-bg-light">#{{ kanban.tag.name }}</span></h1>
<div class="row g-3" x-data="kanbanBoard({{ kanban.id }})">
<section class="col-lg-3 col-md-6"><div class="card item-card shadow-sm h-100"><div class="card-body"><h2 class="h6">Eingang</h2><div class="kanban-column vstack gap-2 p-2 rounded bg-light" data-status="inbox" @dragover.prevent @drop="drop($event)">{% for item in inbox_items %}{% include 'core/_kanban_card.html' %}{% empty %}<div class="text-muted small">Leer.</div>{% endfor %}</div></div></div></section>
<section class="col-lg-3 col-md-6"><div class="card item-card shadow-sm h-100"><div class="card-body"><h2 class="h6">Todo</h2><div class="kanban-column vstack gap-2 p-2 rounded bg-light" data-status="todo" @dragover.prevent @drop="drop($event)">{% for item in todo_items %}{% include 'core/_kanban_card.html' %}{% empty %}<div class="text-muted small">Leer.</div>{% endfor %}</div></div></div></section>
<section class="col-lg-3 col-md-6"><div class="card item-card shadow-sm h-100"><div class="card-body"><h2 class="h6">In Progress</h2><div class="kanban-column vstack gap-2 p-2 rounded bg-light" data-status="progress" @dragover.prevent @drop="drop($event)">{% for item in progress_items %}{% include 'core/_kanban_card.html' %}{% empty %}<div class="text-muted small">Leer.</div>{% endfor %}</div></div></div></section>
<section class="col-lg-3 col-md-6"><div class="card item-card shadow-sm h-100"><div class="card-body"><h2 class="h6">Erledigt</h2><div class="kanban-column vstack gap-2 p-2 rounded bg-light" data-status="done" @dragover.prevent @drop="drop($event)">{% for item in done_items %}{% include 'core/_kanban_card.html' %}{% empty %}<div class="text-muted small">Leer.</div>{% endfor %}</div></div></div></section>
</div>
<script>
function kanbanBoard(id){return{item:null,drag(e){this.item=e.target.closest('[data-id]')},drop(e){let col=e.target.closest('[data-status]');if(!this.item||!col)return;col.querySelectorAll('.text-muted.small').forEach(e=>e.remove());col.appendChild(this.item);this.item.classList.toggle('done', col.dataset.status==='done');fetch(`/kanban/${id}/move/${this.item.dataset.id}/`,{method:'POST',headers:{'X-CSRFToken':'{{ csrf_token }}','Content-Type':'application/x-www-form-urlencoded'},body:`status=${col.dataset.status}`});}}}
</script>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends 'core/base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3"><h1 class="h4 m-0">Kanbans</h1><a class="btn btn-sm btn-outline-secondary" href="/">← zurück</a></div>
<div class="row g-4">
<section class="col-lg-5"><div class="card item-card shadow-sm"><div class="card-body"><h2 class="h5">Neues Kanban</h2><form method="post" class="vstack gap-2">{% csrf_token %}{{ form.name }}{{ form.tag_name }}<button class="btn btn-dark">Anlegen</button></form></div></div></section>
<section class="col-lg-7"><div class="vstack gap-3">{% for s in stats %}<a class="card item-card shadow-sm text-decoration-none text-dark" href="{% url 'kanban_detail' s.kanban.id %}"><div class="card-body"><div class="d-flex justify-content-between"><strong>{{ s.kanban.name }}</strong><span class="badge text-bg-light">#{{ s.kanban.tag.name }}</span></div><div class="small text-muted mt-2">Offen: {{ s.open }} · Erledigt: {{ s.done }}</div></div></a>{% empty %}<div class="text-muted">Noch keine Kanbans.</div>{% endfor %}</div></section>
</div>
{% endblock %}
+3
View File
@@ -8,6 +8,9 @@ urlpatterns = [
path('logout/', auth_views.LogoutView.as_view(), name='logout'), path('logout/', auth_views.LogoutView.as_view(), name='logout'),
path('new/', views.new_item, name='new_item'), path('new/', views.new_item, name='new_item'),
path('journal/', views.journal, name='journal'), path('journal/', views.journal, name='journal'),
path('kanban/', views.kanban_list, name='kanban_list'),
path('kanban/<int:pk>/', views.kanban_detail, name='kanban_detail'),
path('kanban/<int:pk>/move/<int:item_pk>/', views.kanban_move, name='kanban_move'),
path('settings/', views.settings_view, name='settings'), path('settings/', views.settings_view, name='settings'),
path('settings/api-keys/<int:pk>/delete/', views.delete_api_key, name='delete_api_key'), path('settings/api-keys/<int:pk>/delete/', views.delete_api_key, name='delete_api_key'),
path('items/<int:pk>/', views.item_detail, name='item_detail'), path('items/<int:pk>/', views.item_detail, name='item_detail'),
+83 -26
View File
@@ -9,11 +9,12 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.utils import timezone
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST from django.views.decorators.http import require_http_methods, require_POST
from .forms import ApiKeyForm, EditItemForm, QuickItemForm, UserPreferenceForm, UserSettingsForm from .forms import ApiKeyForm, EditItemForm, KanbanForm, QuickItemForm, UserPreferenceForm, UserSettingsForm
from .models import ApiKey, Attachment, Item, Tag, UserPreference from .models import ApiKey, Attachment, Item, Kanban, Tag, UserPreference
COMMAND_RE = re.compile(r'/(todo|note|link|journal|public|private)\b', re.I) COMMAND_RE = re.compile(r'/(todo|note|link|journal|public|private)\b', re.I)
URL_RE = re.compile(r'https?://\S+') URL_RE = re.compile(r'https?://\S+')
@@ -154,19 +155,8 @@ def index(request):
if q: if q:
items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct() 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) 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', { 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 @login_required
def journal(request): 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() today = date.today()
year = int(request.GET.get('year', today.year)) selected_day = request.GET.get('day') or today.isoformat()
month = int(request.GET.get('month', today.month)) selected = date.fromisoformat(selected_day)
entries = visible_items(request.user).filter(kind=Item.Kind.JOURNAL, created_at__year=year, created_at__month=month) year = int(request.GET.get('year', selected.year))
by_day = {} month = int(request.GET.get('month', selected.month))
for item in entries: base = visible_items(request.user)
by_day.setdefault(item.created_at.day, []).append(item) journal_entries = base.filter(kind=Item.Kind.JOURNAL, created_at__date=selected)
weeks = [] created_entries = base.filter(kind__in=[Item.Kind.NOTE, Item.Kind.LINK], created_at__date=selected)
for week in Calendar(firstweekday=0).monthdayscalendar(year, month): completed_todos = base.filter(kind=Item.Kind.TODO, completed_at__date=selected)
weeks.append([(day, by_day.get(day, [])) for day in week]) 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) 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) 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', { return render(request, 'core/journal.html', {
'weeks': weeks, 'year': year, 'month': 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, '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): def toggle_done(request, pk):
item = get_object_or_404(Item, pk=pk, owner=request.user, kind=Item.Kind.TODO) item = get_object_or_404(Item, pk=pk, owner=request.user, kind=Item.Kind.TODO)
item.is_done = not item.is_done 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': if request.headers.get('HX-Request') and item.is_done and request.GET.get('status') != 'archive':
return HttpResponse('') return HttpResponse('')
return render(request, 'core/_item.html', {'item': item}) return render(request, 'core/_item.html', {'item': item})
@@ -305,6 +314,51 @@ def delete_item(request, pk):
return JsonResponse({'ok': True}) 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 @login_required
def tag_list(request): def tag_list(request):
return render(request, 'core/_tags.html', {'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')}) 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, 'comment': item.comment,
'visibility': item.visibility, 'visibility': item.visibility,
'is_done': item.is_done, '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, 'due_at': item.due_at.isoformat() if item.due_at else None,
'url': item.url, 'url': item.url,
'tags': [t.name for t in item.tags.all()], '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']: for field in ['content', 'comment', 'kind', 'visibility', 'is_done', 'due_at', 'url']:
if field in data: if field in data:
setattr(item, field, data[field]) 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.save()
item.sync_metadata() item.sync_metadata()
return JsonResponse(serialize_item(item)) return JsonResponse(serialize_item(item))
+2 -1
View File
@@ -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( ALLOWED_HOSTS = [host.strip() for host in os.environ.get(
'DJANGO_ALLOWED_HOSTS', 'DJANGO_ALLOWED_HOSTS',
'localhost,127.0.0.1,my2dos.rucki.ch', 'localhost,127.0.0.1,testserver,my2dos.rucki.ch',
).split(',') if host.strip()] ).split(',') if host.strip()]
INSTALLED_APPS = [ INSTALLED_APPS = [
@@ -52,6 +52,7 @@ TIME_ZONE = 'Europe/Berlin'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [BASE_DIR / 'static'] if (BASE_DIR / 'static').exists() else [] STATICFILES_DIRS = [BASE_DIR / 'static'] if (BASE_DIR / 'static').exists() else []
MEDIA_URL = 'media/' MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / 'media'