Add kanban updates and static root
This commit is contained in:
+2
-1
@@ -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)
|
||||
|
||||
+16
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -5,7 +5,7 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
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):
|
||||
@@ -34,7 +34,14 @@ class Item(models.Model):
|
||||
content = models.TextField()
|
||||
comment = models.TextField(blank=True)
|
||||
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)
|
||||
completed_at = models.DateTimeField(blank=True, null=True)
|
||||
due_at = models.DateTimeField(blank=True, null=True)
|
||||
url = models.URLField(blank=True)
|
||||
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))
|
||||
|
||||
|
||||
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):
|
||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences')
|
||||
show_notes = models.BooleanField(default=True)
|
||||
|
||||
@@ -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>
|
||||
@@ -7,13 +7,14 @@
|
||||
<title>my2dos</title>
|
||||
<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://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>
|
||||
<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}
|
||||
</style>
|
||||
</head>
|
||||
<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>
|
||||
<script>
|
||||
document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'});
|
||||
|
||||
@@ -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 == '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 == '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 == '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>
|
||||
@@ -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>
|
||||
</div>
|
||||
</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 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' %}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% load markdown_extras %}
|
||||
{% load querystring %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/">← zurück</a>
|
||||
<h1 class="h4 m-0">Journal {{ month }}/{{ year }}</h1>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a class="btn 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>
|
||||
<div class="row g-4">
|
||||
<aside class="col-lg-3 order-lg-2">
|
||||
<div class="card item-card shadow-sm"><div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<a class="btn btn-sm btn-outline-dark" href="?year={{ prev_year }}&month={{ prev_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 class="card item-card shadow-sm"><div class="card-body table-responsive">
|
||||
<table class="table table-bordered align-top mb-0">
|
||||
<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>
|
||||
{% 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>
|
||||
<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>
|
||||
{% 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 %}
|
||||
</tbody></table>
|
||||
<div class="fw-semibold mb-2">Tags</div>
|
||||
{% include 'core/_tags.html' %}
|
||||
</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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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/<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/api-keys/<int:pk>/delete/', views.delete_api_key, name='delete_api_key'),
|
||||
path('items/<int:pk>/', views.item_detail, name='item_detail'),
|
||||
|
||||
+83
-26
@@ -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))
|
||||
|
||||
+2
-1
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user