Improve teams settings invites and due tasks
This commit is contained in:
+2
-1
@@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserPreference
|
||||
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
|
||||
|
||||
|
||||
class AttachmentInline(admin.TabularInline):
|
||||
@@ -22,6 +22,7 @@ class ApiKeyAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
admin.site.register(Tag)
|
||||
admin.site.register(UserInvite)
|
||||
admin.site.register(Team)
|
||||
admin.site.register(TeamMembership)
|
||||
admin.site.register(TeamInvite)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from .models import UserPreference
|
||||
|
||||
|
||||
def overview_navigation(request):
|
||||
if not request.user.is_authenticated:
|
||||
return {'nav_overview_labels': [_('Notes'), _('Tasks'), _('Links')], 'nav_hidden_links': []}
|
||||
prefs, _created = UserPreference.objects.get_or_create(user=request.user)
|
||||
overview = []
|
||||
hidden = []
|
||||
task_shown = prefs.show_open_todos or prefs.show_done_todos
|
||||
items = [
|
||||
(task_shown, _('Tasks'), f"{reverse('index')}?kind=todo"),
|
||||
(prefs.show_notes, _('Notes'), f"{reverse('index')}?kind=note"),
|
||||
(prefs.show_links, _('Links'), f"{reverse('index')}?kind=link"),
|
||||
]
|
||||
for shown, label, url in items:
|
||||
if shown:
|
||||
overview.append(label)
|
||||
else:
|
||||
hidden.append({'label': label, 'url': url})
|
||||
return {'nav_overview_labels': overview or [_('Overview')], 'nav_hidden_links': hidden}
|
||||
+32
-10
@@ -1,7 +1,8 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import UserCreationForm
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, UserPreference
|
||||
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference
|
||||
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
@@ -38,17 +39,12 @@ class EditItemForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
fields = ['content', 'comment', 'visibility', 'due_at', 'url']
|
||||
fields = ['content', 'comment', 'visibility', 'url']
|
||||
widgets = {
|
||||
'comment': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'due_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'),
|
||||
'url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'URL'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['due_at'].input_formats = ['%Y-%m-%dT%H:%M']
|
||||
|
||||
|
||||
class UserSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
@@ -60,16 +56,42 @@ class UserSettingsForm(forms.ModelForm):
|
||||
class UserPreferenceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = ['show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'show_journal', 'overview_lines']
|
||||
fields = ['language', 'show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'due_first', 'overview_lines']
|
||||
labels = {
|
||||
'language': _('Language'),
|
||||
'show_notes': _('Show notes'),
|
||||
'show_open_todos': _('Show open tasks'),
|
||||
'show_done_todos': _('Show completed tasks'),
|
||||
'show_links': _('Show links'),
|
||||
'show_journal': _('Show journal'),
|
||||
'due_first': _('Always show due items first'),
|
||||
'overview_lines': _('Lines in overview'),
|
||||
}
|
||||
widgets = {'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50})}
|
||||
widgets = {
|
||||
'language': forms.Select(attrs={'class': 'form-select'}),
|
||||
'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50}),
|
||||
}
|
||||
|
||||
|
||||
class UserInviteForm(forms.ModelForm):
|
||||
email = forms.EmailField(required=True, label=_('Email'), widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'person@example.com', 'required': True}))
|
||||
|
||||
class Meta:
|
||||
model = UserInvite
|
||||
fields = ['email']
|
||||
|
||||
|
||||
class InviteRegistrationForm(UserCreationForm):
|
||||
email = forms.EmailField(required=False, label=_('Email'), widget=forms.EmailInput(attrs={'class': 'form-control'}))
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'email', 'password1', 'password2']
|
||||
widgets = {'username': forms.TextInput(attrs={'class': 'form-control'})}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for name in ['password1', 'password2']:
|
||||
self.fields[name].widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
|
||||
class TeamForm(forms.ModelForm):
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.utils import translation
|
||||
|
||||
|
||||
class UserLanguageMiddleware:
|
||||
"""Prefer the authenticated user's saved language over browser/cookie language."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
user = getattr(request, 'user', None)
|
||||
language = ''
|
||||
if user and user.is_authenticated:
|
||||
try:
|
||||
language = user.preferences.language
|
||||
except Exception:
|
||||
language = ''
|
||||
if language:
|
||||
translation.activate(language)
|
||||
request.LANGUAGE_CODE = language
|
||||
response = self.get_response(request)
|
||||
if language:
|
||||
response.setdefault('Content-Language', language)
|
||||
return response
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-23 13:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_alter_item_kanban_status_alter_item_kind_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='language',
|
||||
field=models.CharField(blank=True, choices=[('', 'Browser default'), ('de', 'Deutsch'), ('en', 'English')], default='', max_length=10),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-23 13:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_userpreference_language'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserInvite',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email', models.EmailField(blank=True, max_length=254)),
|
||||
('token', models.CharField(editable=False, max_length=80, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('accepted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('invited_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_user_invites', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-23 14:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_userinvite'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userinvite',
|
||||
name='email',
|
||||
field=models.EmailField(max_length=254),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-23 14:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0014_alter_userinvite_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='due_first',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -3,6 +3,7 @@ import secrets
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
TAG_RE = re.compile(r'(?<!\w)#([\wäöüÄÖÜß-]+)')
|
||||
@@ -19,6 +20,25 @@ class Tag(models.Model):
|
||||
return f'#{self.name}'
|
||||
|
||||
|
||||
class UserInvite(models.Model):
|
||||
email = models.EmailField()
|
||||
token = models.CharField(max_length=80, unique=True, editable=False)
|
||||
invited_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sent_user_invites')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
accepted_at = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.token:
|
||||
self.token = secrets.token_urlsafe(32)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f'Benutzereinladung {self.email}'
|
||||
|
||||
|
||||
class Team(models.Model):
|
||||
name = models.CharField(max_length=120)
|
||||
slug = models.SlugField(max_length=80, unique=True, allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos')
|
||||
@@ -112,6 +132,14 @@ class Item(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse('item_detail', args=[self.pk])
|
||||
|
||||
@property
|
||||
def is_overdue(self):
|
||||
return self.kind == self.Kind.TODO and not self.is_done and self.due_at and self.due_at < timezone.now()
|
||||
|
||||
@property
|
||||
def is_due_today(self):
|
||||
return self.kind == self.Kind.TODO and not self.is_done and self.due_at and timezone.localdate(self.due_at) == timezone.localdate()
|
||||
|
||||
def sync_metadata(self):
|
||||
tag_names = {m.group(1).lower() for m in TAG_RE.finditer(self.content)}
|
||||
tags = [Tag.objects.get_or_create(name=name)[0] for name in tag_names]
|
||||
@@ -136,11 +164,13 @@ class Kanban(models.Model):
|
||||
|
||||
class UserPreference(models.Model):
|
||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences')
|
||||
language = models.CharField(max_length=10, blank=True, default='', choices=[('', _('Browser default')), ('de', 'Deutsch'), ('en', 'English')])
|
||||
show_notes = models.BooleanField(default=True)
|
||||
show_open_todos = models.BooleanField(default=True)
|
||||
show_done_todos = models.BooleanField(default=False)
|
||||
show_links = models.BooleanField(default=True)
|
||||
show_journal = models.BooleanField(default=False)
|
||||
due_first = models.BooleanField(default=True)
|
||||
overview_lines = models.PositiveSmallIntegerField(default=4)
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option>
|
||||
<option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</option>
|
||||
</select></div>
|
||||
{% if item.kind == 'todo' %}<div class="col-md-4"><input class="form-control" type="datetime-local" name="due_at" value="{{ item.due_at|date:'Y-m-d\\TH:i' }}" title="{% trans 'Date/time for todo' %}"></div>{% endif %}
|
||||
{% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}"><datalist id="due-times-{{ item.id }}"><option value="00:00"><option value="00:30"><option value="01:00"><option value="01:30"><option value="02:00"><option value="02:30"><option value="03:00"><option value="03:30"><option value="04:00"><option value="04:30"><option value="05:00"><option value="05:30"><option value="06:00"><option value="06:30"><option value="07:00"><option value="07:30"><option value="08:00"><option value="08:30"><option value="09:00"><option value="09:30"><option value="10:00"><option value="10:30"><option value="11:00"><option value="11:30"><option value="12:00"><option value="12:30"><option value="13:00"><option value="13:30"><option value="14:00"><option value="14:30"><option value="15:00"><option value="15:30"><option value="16:00"><option value="16:30"><option value="17:00"><option value="17:30"><option value="18:00"><option value="18:30"><option value="19:00"><option value="19:30"><option value="20:00"><option value="20:30"><option value="21:00"><option value="21:30"><option value="22:00"><option value="22:30"><option value="23:00"><option value="23:30"></datalist></div>{% endif %}
|
||||
<div class="col-md-4"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"></div>
|
||||
</div>
|
||||
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% load markdown_extras i18n %}
|
||||
<article id="item-{{ item.id }}" class="card item-card shadow-sm {% if item.is_done %}done{% endif %}" ondblclick="if(!event.target.closest('a,button,input,textarea,select,label')) window.location.href='{{ item.get_absolute_url }}?next={{ request.get_full_path|urlencode }}'">
|
||||
<article id="item-{{ item.id }}" class="card item-card shadow-sm {% if item.is_done %}done{% elif item.is_overdue %}border-danger border-2{% elif item.is_due_today %}border-warning border-2{% endif %}" ondblclick="if(!event.target.closest('a,button,input,textarea,select,label')) window.location.href='{{ item.get_absolute_url }}?next={{ request.get_full_path|urlencode }}'">
|
||||
<div class="card-body">
|
||||
<div class="item-row d-flex justify-content-between align-items-start gap-3">
|
||||
<div class="item-main flex-grow-1 min-w-0" style="min-width:0">
|
||||
@@ -14,7 +14,7 @@
|
||||
{% if overview_lines %}<button x-show="showMore" type="button" class="btn btn-link btn-sm p-0 mt-1" @click="expanded=!expanded" x-text="expanded ? '{% trans "show less" %}' : '{% trans "show more" %}'"></button>{% endif %}
|
||||
</div>
|
||||
{% if item.comment %}<div class="border-start ps-2 mt-2 small content text-muted">{{ item.comment|markdown }}</div>{% endif %}
|
||||
{% if item.due_at %}<div class="small text-warning-emphasis mt-2">{% trans "Due:" %} {{ item.due_at|date:'d.m.Y H:i' }}</div>{% endif %}
|
||||
{% if item.due_at %}<div class="small mt-2 {% if item.is_overdue %}text-danger fw-semibold{% elif item.is_due_today %}text-warning-emphasis fw-semibold{% else %}text-warning-emphasis{% endif %}">{% if item.is_overdue %}<span class="badge text-bg-danger me-1">{% trans "Overdue" %}</span>{% elif item.is_due_today %}<span class="badge text-bg-warning me-1">{% trans "Due today" %}</span>{% endif %}{% trans "Due:" %} {{ item.due_at|date:'d.m.Y H:i' }}</div>{% endif %}
|
||||
{% if item.url %}<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.url }}</a>{% endif %}
|
||||
{% if item.linked_items.all %}<div class="small mt-2">{% trans "Links:" %} {% for linked in item.linked_items.all %}<a href="{{ linked.get_absolute_url }}">#{{ linked.id }}</a> {% endfor %}</div>{% endif %}
|
||||
{% if item.attachments.all %}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% load querystring i18n %}
|
||||
<div id="tag-list" class="d-flex flex-wrap gap-2"{% if oob %} hx-swap-oob="true"{% endif %}>
|
||||
{% for tag in tags %}
|
||||
{% if active_tag == tag.name %}
|
||||
<a class="badge rounded-pill text-bg-dark text-decoration-none tag" href="{% query_replace tag=None %}">#{{ tag.name }} ×</a>
|
||||
{% if tag.name in active_tags %}
|
||||
<a class="badge rounded-pill text-bg-dark text-decoration-none tag tag-filter" data-tag="{{ tag.name }}" href="{% query_replace tag=None %}">#{{ tag.name }} ×</a>
|
||||
{% else %}
|
||||
<a class="badge rounded-pill text-bg-light text-decoration-none tag" href="{% query_replace tag=tag.name %}">#{{ tag.name }}</a>
|
||||
<a class="badge rounded-pill text-bg-light text-decoration-none tag tag-filter" data-tag="{{ tag.name }}" href="{% query_replace tag=tag.name %}">#{{ tag.name }}</a>
|
||||
{% endif %}
|
||||
{% empty %}<span class="text-muted small">{% trans "No tags yet" %}</span>{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center"><div class="col-md-6">
|
||||
<div class="card item-card shadow-sm"><div class="card-body">
|
||||
<h1 class="h4 mb-2">{% trans "Create account" %}</h1>
|
||||
<p class="text-muted">{% trans "You were invited to my2dos. Create your account to continue." %}</p>
|
||||
<form method="post" class="vstack gap-3">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
|
||||
{% for field in form %}
|
||||
<div>
|
||||
<label class="form-label">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
|
||||
{% for error in field.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button class="btn btn-dark">{% trans "Create account" %}</button>
|
||||
</form>
|
||||
</div></div>
|
||||
</div></div>
|
||||
{% endblock %}
|
||||
@@ -17,11 +17,11 @@
|
||||
<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}.mobile-create-buttons,.mobile-create-nav,.mobile-tags,.mobile-media,.mobile-tag-picker{display:none}.mobile-tag-popup{position:fixed;inset:auto .75rem 1rem .75rem;z-index:1050;max-height:55vh;overflow:auto}.mobile-tag-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.25);z-index:1040}.camera-input{max-width:13rem}@media (max-width: 767.98px){.shell{max-width:100%}main.container{padding-left:.75rem;padding-right:.75rem}.navbar .container{gap:.5rem}.navbar-brand{font-size:1rem}.navbar .d-flex{gap:.5rem!important}.item-card{border-radius:.75rem}.mobile-create-buttons,.mobile-create-nav,.mobile-tags,.mobile-media{display:flex!important}.mobile-tag-picker{display:block!important}.desktop-quick-create{display:none!important}.mobile-create-nav{position:sticky;top:57px;z-index:10;background:#f7f7f4;padding:.35rem 0}.desktop-file{display:none!important}.mobile-create-buttons{position:sticky;top:57px;z-index:10;background:#f7f7f4;padding:.35rem 0}.quick-actions{flex-wrap:wrap}.quick-actions .form-control{min-width:100%}.slash-menu{width:calc(100vw - 1.5rem)}textarea.form-control-lg{font-size:1rem}.btn{touch-action:manipulation}.item-row{flex-direction:column}.item-actions{align-self:flex-end}.item-main{width:100%;min-width:0}}
|
||||
body{background:linear-gradient(180deg,#f8fafc 0,#f7f7f4 18rem);color:#1f2937}.shell{max-width:960px}.navbar{box-shadow:0 .35rem 1.25rem rgba(15,23,42,.06)}.navbar a{text-decoration:none}.nav-soft{color:#475569!important;font-weight:500;padding:.2rem .35rem;border-radius:.5rem}.nav-soft:hover{color:#0d6efd!important;background:#eff6ff}.user-menu-btn{min-width:9rem}.card{border:0}.item-card{border:0;border-radius:1.1rem;box-shadow:0 .45rem 1.4rem rgba(15,23,42,.07)!important}.filter-panel{padding:.25rem .15rem}.border{border-color:rgba(15,23,42,.10)!important}.text-bg-light{background:#f1f5f9!important;color:#475569!important}.btn{border-radius:.7rem}.form-control,.form-select{border-color:#e2e8f0;border-radius:.75rem}.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}.mobile-create-buttons,.mobile-create-nav,.mobile-tags,.mobile-media,.mobile-tag-picker{display:none}.mobile-tag-popup{position:fixed;inset:auto .75rem 1rem .75rem;z-index:1050;max-height:55vh;overflow:auto}.mobile-tag-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.25);z-index:1040}.camera-input{max-width:13rem}@media (max-width: 767.98px){.shell{max-width:100%}main.container{padding-left:.75rem;padding-right:.75rem}.navbar .container{gap:.5rem}.navbar-brand{font-size:1rem}.navbar .d-flex{gap:.5rem!important}.item-card{border-radius:.75rem}.mobile-create-buttons,.mobile-create-nav,.mobile-tags,.mobile-media{display:flex!important}.mobile-tag-picker{display:block!important}.desktop-quick-create{display:none!important}.mobile-create-nav{position:sticky;top:57px;z-index:10;background:#f7f7f4;padding:.35rem 0}.desktop-file{display:none!important}.mobile-create-buttons{position:sticky;top:57px;z-index:10;background:#f7f7f4;padding:.35rem 0}.quick-actions{flex-wrap:wrap}.quick-actions .form-control{min-width:100%}.slash-menu{width:calc(100vw - 1.5rem)}textarea.form-control-lg{font-size:1rem}.btn{touch-action:manipulation}.item-row{flex-direction:column}.item-actions{align-self:flex-end}.item-main{width:100%;min-width:0}}
|
||||
</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"><a class="small d-none d-sm-inline text-primary text-decoration-underline" href="/">{% trans "Notes · Tasks · Links" %}</a>{% if user.is_authenticated %}<a class="small" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small" href="{% url 'kanban_list' %}">{% trans "Kanban" %}</a><a class="small" href="{% url 'settings' %}#teams">{% trans "Teams" %}</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' %}">{% trans "Settings" %}</a></li><li><button id="pwa-install" type="button" class="dropdown-item d-none">{% trans "Install app" %}</button></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">{% trans "Logout" %}</button></form></li></ul></div>{% else %}<a class="btn btn-sm btn-dark" href="{% url 'login' %}">{% trans "Login" %}</a>{% endif %}<form action="{% url 'set_language' %}" method="post" class="d-flex align-items-center">{% csrf_token %}<input name="next" type="hidden" value="{{ request.get_full_path }}"><select name="language" class="form-select form-select-sm" onchange="this.form.submit()">{% get_current_language as LANGUAGE_CODE %}{% get_available_languages as LANGUAGES %}{% for code, name in LANGUAGES %}<option value="{{ code }}"{% if code == LANGUAGE_CODE %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></form></div></div></nav>
|
||||
<nav class="navbar bg-white 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="small d-none d-sm-inline"><a class="nav-soft" href="/">{% for label in nav_overview_labels %}{{ label }}{% if not forloop.last %} · {% endif %}{% endfor %}</a>{% for link in nav_hidden_links %}<a class="nav-soft ms-2" href="{{ link.url }}">{{ link.label }}</a>{% endfor %}</span>{% if user.is_authenticated %}<a class="small nav-soft" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small nav-soft" href="{% url 'kanban_list' %}">{% trans "Kanban" %}</a><a class="small nav-soft" href="{% url 'team_list' %}">{% trans "Teams" %}</a><div class="dropdown"><button class="btn btn-sm btn-outline-secondary dropdown-toggle user-menu-btn" 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' %}">{% trans "Settings" %}</a></li><li><button id="pwa-install" type="button" class="dropdown-item d-none">{% trans "Install app" %}</button></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">{% trans "Logout" %}</button></form></li></ul></div>{% else %}<a class="btn btn-sm btn-dark" href="{% url 'login' %}">{% trans "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 }}'});
|
||||
@@ -45,6 +45,7 @@ window.quickComposer = window.quickComposer || function(tags, itemId=null){
|
||||
window.searchBox = function(tags){return{open:false,active:0,q:'',tags:tags||[],get filtered(){let q=this.q.toLowerCase();return this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q))},onInput(e){this.q=e.target.value;this.open=this.q.startsWith('#')},onKeydown(e){if(!this.open)return;if(e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%Math.max(this.filtered.length,1)}else if(e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+Math.max(this.filtered.length,1))%Math.max(this.filtered.length,1)}else if(e.key==='Enter'||e.key==='Tab'){if(this.filtered.length){e.preventDefault();this.select(this.filtered[this.active])}}else if(e.key==='Escape'){this.open=false}},select(tag){let url=new URL(window.location.href);url.searchParams.set('tag',tag);url.searchParams.delete('q');window.location.href=url.pathname+'?'+url.searchParams.toString()}}}
|
||||
function enhanceCodeBlocks(root=document){root.querySelectorAll('.content pre:not([data-copy])').forEach(pre=>{pre.dataset.copy='1';let btn=document.createElement('button');btn.type='button';btn.className='copy-code btn btn-sm btn-light';btn.title='Kopieren';btn.innerHTML='⧉';btn.addEventListener('click',()=>navigator.clipboard.writeText(pre.querySelector('code')?.innerText || pre.innerText).then(()=>{btn.innerHTML='✓';setTimeout(()=>btn.innerHTML='⧉',1200)}));pre.appendChild(btn)})}
|
||||
document.addEventListener('DOMContentLoaded',()=>enhanceCodeBlocks());document.body.addEventListener('htmx:afterSwap',e=>enhanceCodeBlocks(e.target));
|
||||
document.addEventListener('click', event => {let link=event.target.closest('a.tag-filter[data-tag]');if(!link||!(event.ctrlKey||event.metaKey))return;event.preventDefault();let url=new URL(window.location.href);let tag=link.dataset.tag;let tags=url.searchParams.getAll('tag');url.searchParams.delete('tag');if(tags.includes(tag)){tags=tags.filter(t=>t!==tag)}else{tags.push(tag)}tags.forEach(t=>url.searchParams.append('tag',t));window.location.href=url.pathname+(url.searchParams.toString()?'?'+url.searchParams.toString():'')});
|
||||
let deferredPwaPrompt=null;window.addEventListener('beforeinstallprompt',event=>{event.preventDefault();deferredPwaPrompt=event;document.getElementById('pwa-install')?.classList.remove('d-none')});document.addEventListener('click',async event=>{if(event.target?.id==='pwa-install'&&deferredPwaPrompt){deferredPwaPrompt.prompt();await deferredPwaPrompt.userChoice;deferredPwaPrompt=null;event.target.classList.add('d-none')}});
|
||||
if('serviceWorker' in navigator){window.addEventListener('load',()=>navigator.serviceWorker.register('/sw.js').catch(()=>{}));}
|
||||
</script>
|
||||
|
||||
@@ -9,17 +9,19 @@
|
||||
</div>
|
||||
<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="filter-panel">
|
||||
<div class="fw-semibold mb-2">{% trans "Filter" %}</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<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 %}">{% trans "Notes" %}{% 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_status == 'open' %}btn-warning{% else %}btn-outline-warning{% endif %}" href="{% if active_status == 'open' %}{% query_replace status=None %}{% else %}{% query_replace status='open' %}{% endif %}">{% trans "Open" %}{% if active_status == 'open' %} ×{% endif %}</a>
|
||||
<a class="btn btn-sm {% if active_status == 'due' %}btn-danger{% else %}btn-outline-danger{% endif %}" href="{% if active_status == 'due' %}{% query_replace status=None %}{% else %}{% query_replace status='due' %}{% endif %}">{% trans "Due" %} <span class="badge {% if active_status == 'due' %}text-bg-light{% else %}text-bg-danger{% endif %}">{{ due_count }}</span>{% if active_status == 'due' %} ×{% 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 %}">{% trans "Archive" %}{% if active_status == 'archive' %} ×{% endif %}</a>
|
||||
</div>
|
||||
<form class="mb-3 position-relative" method="get" x-data="searchBox([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:keydown.window="if(($event.ctrlKey||$event.metaKey)&&$event.key==='f'){ $event.preventDefault(); $refs.search.focus(); $refs.search.select(); }">
|
||||
{% if active_tag %}<input type="hidden" name="tag" value="{{ active_tag }}">{% endif %}
|
||||
{% for tag_name in active_tags %}<input type="hidden" name="tag" value="{{ tag_name }}">{% endfor %}
|
||||
{% if active_tag_mode == 'all' %}<input type="hidden" name="tag_mode" value="all">{% endif %}
|
||||
{% if active_kind %}<input type="hidden" name="kind" value="{{ active_kind }}">{% endif %}
|
||||
{% if active_status %}<input type="hidden" name="status" value="{{ active_status }}">{% endif %}
|
||||
<div class="input-group input-group-sm">
|
||||
@@ -31,20 +33,29 @@
|
||||
</div>
|
||||
</form>
|
||||
<div class="d-none d-md-block">
|
||||
<div class="fw-semibold mb-2">{% trans "Tags" %}</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="fw-semibold">{% trans "Tags" %}</div>
|
||||
{% if active_tags|length > 1 %}
|
||||
<div class="btn-group btn-group-sm" title="{% trans 'Tag mode' %}">
|
||||
<a class="btn {% if active_tag_mode == 'any' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="{% query_replace tag_mode=None %}">{% trans "any" %}</a>
|
||||
<a class="btn {% if active_tag_mode == 'all' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="{% query_replace tag_mode='all' %}">{% trans "all" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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' %}
|
||||
</div>
|
||||
</div>
|
||||
<details class="d-md-none">
|
||||
<summary class="btn btn-sm btn-outline-secondary mb-2">{% trans "Show tags" %}</summary>
|
||||
{% if active_tags|length > 1 %}<div class="btn-group btn-group-sm mb-2"><a class="btn {% if active_tag_mode == 'any' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="{% query_replace tag_mode=None %}">{% trans "any" %}</a><a class="btn {% if active_tag_mode == 'all' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="{% query_replace tag_mode='all' %}">{% trans "all" %}</a></div>{% endif %}
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for tag in tags %}
|
||||
{% if active_tag == tag.name %}<a class="badge rounded-pill text-bg-dark text-decoration-none tag" href="{% query_replace tag=None %}">#{{ tag.name }} ×</a>{% else %}<a class="badge rounded-pill text-bg-light text-decoration-none tag" href="{% query_replace tag=tag.name %}">#{{ tag.name }}</a>{% endif %}
|
||||
{% if tag.name in active_tags %}<a class="badge rounded-pill text-bg-dark text-decoration-none tag tag-filter" data-tag="{{ tag.name }}" href="{% query_replace tag=None %}">#{{ tag.name }} ×</a>{% else %}<a class="badge rounded-pill text-bg-light text-decoration-none tag tag-filter" data-tag="{{ tag.name }}" href="{% query_replace tag=tag.name %}">#{{ tag.name }}</a>{% endif %}
|
||||
{% empty %}<span class="text-muted small">{% trans "No tags yet" %}</span>{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</div></div>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="col-lg-9">
|
||||
<form class="desktop-quick-create 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()}">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><select class="form-select" name="visibility"><option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option><option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</option></select></div>
|
||||
{% if item.kind == 'todo' %}<div class="col-md-4"><input class="form-control" type="datetime-local" name="due_at" value="{{ item.due_at|date:'Y-m-d\\TH:i' }}"></div>{% endif %}
|
||||
{% if item.kind == 'todo' %}<div class="col-md-3"><input class="form-control" type="date" name="due_date" value="{{ item.due_at|date:'Y-m-d' }}" title="{% trans 'Due date' %}"></div><div class="col-md-3"><input class="form-control" name="due_time" list="due-times-{{ item.id }}" value="{{ item.due_at|date:'H:i' }}" placeholder="{% trans 'Time optional' %}" title="{% trans 'Time optional' %}"><datalist id="due-times-{{ item.id }}"><option value="00:00"><option value="00:30"><option value="01:00"><option value="01:30"><option value="02:00"><option value="02:30"><option value="03:00"><option value="03:30"><option value="04:00"><option value="04:30"><option value="05:00"><option value="05:30"><option value="06:00"><option value="06:30"><option value="07:00"><option value="07:30"><option value="08:00"><option value="08:30"><option value="09:00"><option value="09:30"><option value="10:00"><option value="10:30"><option value="11:00"><option value="11:30"><option value="12:00"><option value="12:30"><option value="13:00"><option value="13:30"><option value="14:00"><option value="14:30"><option value="15:00"><option value="15:30"><option value="16:00"><option value="16:30"><option value="17:00"><option value="17:30"><option value="18:00"><option value="18:30"><option value="19:00"><option value="19:30"><option value="20:00"><option value="20:30"><option value="21:00"><option value="21:30"><option value="22:00"><option value="22:30"><option value="23:00"><option value="23:30"></datalist></div>{% endif %}
|
||||
<div class="col-md-4"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"></div>
|
||||
</div>
|
||||
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
{% block content %}
|
||||
<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="filter-panel">
|
||||
<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>
|
||||
<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>
|
||||
<table class="table table-sm text-center mb-3 small"><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">{% trans "Tags" %}</div>
|
||||
{% include 'core/_tags.html' %}
|
||||
</div></div>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="col-lg-9">
|
||||
<h1 class="h4 mb-3">Journal {{ selected_day }}</h1>
|
||||
|
||||
@@ -2,60 +2,87 @@
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← {% trans "back" %}</a>
|
||||
<div class="row g-4">
|
||||
<section class="col-lg-6">
|
||||
<div class="card item-card shadow-sm"><div class="card-body">
|
||||
<h1 class="h4 mb-3">{% trans "User settings" %}</h1>
|
||||
<form method="post" class="vstack gap-3">
|
||||
<div class="row g-4 align-items-start">
|
||||
<aside class="col-lg-3">
|
||||
<div class="card item-card shadow-sm sticky-lg-top" style="top:5rem"><div class="card-body">
|
||||
<h1 class="h5 mb-3">{% trans "Settings" %}</h1>
|
||||
<nav class="nav nav-pills flex-column gap-1">
|
||||
<a class="nav-link" href="#profile">{% trans "Profile" %}</a>
|
||||
<a class="nav-link" href="#preferences">{% trans "View & language" %}</a>
|
||||
<a class="nav-link" href="#invites">{% trans "Invitations" %}</a>
|
||||
<a class="nav-link" href="#api">{% trans "API keys" %}</a>
|
||||
</nav>
|
||||
</div></div>
|
||||
</aside>
|
||||
|
||||
<section class="col-lg-9 vstack gap-4">
|
||||
<div id="profile" class="card item-card shadow-sm"><div class="card-body">
|
||||
<h2 class="h4 mb-1">{% trans "Profile" %}</h2>
|
||||
<p class="text-muted small mb-3">{% trans "Manage your personal user data." %}</p>
|
||||
<form method="post" class="row g-3">
|
||||
{% csrf_token %}<input type="hidden" name="action" value="profile">
|
||||
<div><label class="form-label">{% trans "First name" %}</label>{{ profile_form.first_name }}</div>
|
||||
<div><label class="form-label">{% trans "Last name" %}</label>{{ profile_form.last_name }}</div>
|
||||
<div><label class="form-label">{% trans "Email" %}</label>{{ profile_form.email }}</div>
|
||||
<button class="btn btn-dark">{% trans "Save" %}</button>
|
||||
<div class="col-md-6"><label class="form-label">{% trans "First name" %}</label>{{ profile_form.first_name }}</div>
|
||||
<div class="col-md-6"><label class="form-label">{% trans "Last name" %}</label>{{ profile_form.last_name }}</div>
|
||||
<div class="col-12"><label class="form-label">{% trans "Email" %}</label>{{ profile_form.email }}</div>
|
||||
<div class="col-12"><button class="btn btn-dark">{% trans "Save profile" %}</button></div>
|
||||
</form>
|
||||
</div></div>
|
||||
</section>
|
||||
<section class="col-lg-6">
|
||||
<div class="card item-card shadow-sm mb-3"><div class="card-body">
|
||||
<h2 class="h5 mb-3">{% trans "Default view" %}</h2>
|
||||
<form method="post" class="vstack gap-2">
|
||||
|
||||
<div id="preferences" class="card item-card shadow-sm"><div class="card-body">
|
||||
<h2 class="h4 mb-1">{% trans "View & language" %}</h2>
|
||||
<p class="text-muted small mb-3">{% trans "Choose your language and configure the default overview." %}</p>
|
||||
<form method="post" class="vstack gap-3">
|
||||
{% csrf_token %}<input type="hidden" name="action" value="preferences">
|
||||
{% for field in preference_form %}
|
||||
{% if field.field.widget.input_type == 'checkbox' %}
|
||||
<label class="form-check"><input class="form-check-input" type="checkbox" name="{{ field.name }}" {% if field.value %}checked{% endif %}> <span class="form-check-label">{{ field.label }}</span></label>
|
||||
{% else %}
|
||||
<div><label class="form-label">{{ field.label }}</label>{{ field }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<button class="btn btn-dark mt-2">{% trans "Save default view" %}</button>
|
||||
<div class="row g-3">
|
||||
{% for field in preference_form %}
|
||||
{% if field.name == 'language' or field.name == 'overview_lines' %}
|
||||
<div class="col-md-6"><label class="form-label">{{ field.label }}</label>{{ field }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="col-12">
|
||||
<div class="form-label mb-2">{% trans "Sorting" %}</div>
|
||||
<label class="form-check border rounded p-3"><input class="form-check-input" type="checkbox" name="due_first" {% if preference_form.due_first.value %}checked{% endif %}> <span class="form-check-label fw-semibold">{{ preference_form.due_first.label }}</span><div class="small text-muted">{% trans "Overdue and due-today tasks are sorted to the top of the overview." %}</div></label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-label mb-2">{% trans "Show in overview" %}</div>
|
||||
<div class="row g-2">
|
||||
{% for field in preference_form %}
|
||||
{% if field.field.widget.input_type == 'checkbox' and field.name != 'due_first' %}
|
||||
<div class="col-md-6"><label class="form-check border rounded p-2 ps-4 h-100"><input class="form-check-input" type="checkbox" name="{{ field.name }}" {% if field.value %}checked{% endif %}> <span class="form-check-label">{{ field.label }}</span></label></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div><button class="btn btn-dark">{% trans "Save view settings" %}</button></div>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="card item-card shadow-sm mb-3"><div class="card-body">
|
||||
<h2 id="teams" class="h5 mb-3">{% trans "Teams" %}</h2>
|
||||
<form method="post" class="vstack gap-3 mb-3">
|
||||
{% csrf_token %}<input type="hidden" name="action" value="team">
|
||||
<div><label class="form-label">{% trans "Team name" %}</label>{{ team_form.name }}</div>
|
||||
<div><label class="form-label">{% trans "Team tag" %}</label><div class="small text-muted mb-2">{% trans "This tag automatically turns items into team items, e.g. #my2dos." %}</div>{{ team_form.slug }}</div>
|
||||
<button class="btn btn-dark">{% trans "Create team" %}</button>
|
||||
|
||||
<div id="invites" class="card item-card shadow-sm"><div class="card-body">
|
||||
<h2 class="h4 mb-1">{% trans "User invitations" %}</h2>
|
||||
<p class="text-muted small mb-3">{% trans "Invite new users during the test phase. Only people with an invitation link can register." %}</p>
|
||||
<form method="post" class="row g-3 mb-4">
|
||||
{% csrf_token %}<input type="hidden" name="action" value="userinvite">
|
||||
<div class="col-md-8"><label class="form-label">{% trans "Email" %}</label>{{ user_invite_form.email }}</div>
|
||||
<div class="col-md-4 d-flex align-items-end"><button class="btn btn-dark w-100">{% trans "Create invitation link" %}</button></div>
|
||||
</form>
|
||||
<div class="vstack gap-2">
|
||||
{% for team in teams %}<a class="border rounded p-2 text-decoration-none" href="{% url 'team_detail' team.id %}"><strong>{{ team.name }}</strong> <span class="text-muted">#{{ team.slug }}</span></a>{% empty %}<span class="text-muted">{% trans "No teams yet." %}</span>{% endfor %}
|
||||
{% for invite in user_invites %}<div id="invite-{{ invite.id }}" class="border rounded p-3"><div class="d-flex justify-content-between gap-2"><strong>{{ invite.email }}</strong><form method="post" action="{% url 'delete_user_invite' invite.id %}" hx-post="{% url 'delete_user_invite' invite.id %}" hx-target="#invite-{{ invite.id }}" hx-swap="outerHTML">{% csrf_token %}<button class="btn btn-sm btn-outline-danger">{% trans "Delete" %}</button></form></div><code class="small user-select-all">{{ request.scheme }}://{{ request.get_host }}{% url 'accept_user_invite' invite.token %}</code></div>{% empty %}<span class="text-muted">{% trans "No open invitations." %}</span>{% endfor %}
|
||||
</div>
|
||||
</div></div>
|
||||
<div class="card item-card shadow-sm mb-3"><div class="card-body">
|
||||
<h2 class="h5 mb-3">{% trans "Create API key" %}</h2>
|
||||
<form method="post" class="vstack gap-3">
|
||||
|
||||
<div id="api" class="card item-card shadow-sm"><div class="card-body">
|
||||
<h2 class="h4 mb-1">{% trans "API keys" %}</h2>
|
||||
<p class="text-muted small mb-3">{% trans "Create API keys for integrations and optionally restrict them to tags." %}</p>
|
||||
<form method="post" class="vstack gap-3 mb-4">
|
||||
{% csrf_token %}<input type="hidden" name="action" value="apikey">
|
||||
<div><label class="form-label">{% trans "Name" %}</label>{{ key_form.name }}</div>
|
||||
<div><label class="form-label">{% trans "Restrict to tags" %}</label><div class="small text-muted mb-2">{% trans "No selection = access to all own items." %}</div>{{ key_form.tags }}</div>
|
||||
<button class="btn btn-dark">{% trans "Generate API key" %}</button>
|
||||
<button class="btn btn-dark align-self-start">{% trans "Generate API key" %}</button>
|
||||
</form>
|
||||
</div></div>
|
||||
<div class="card item-card shadow-sm"><div class="card-body">
|
||||
<h2 class="h5 mb-3">{% trans "API keys" %}</h2>
|
||||
<div class="vstack gap-2">
|
||||
{% for key in api_keys %}
|
||||
<div class="border rounded p-2">
|
||||
<div class="border rounded p-3">
|
||||
<div class="d-flex justify-content-between gap-2"><strong>{{ key.name }}</strong><form method="post" action="{% url 'delete_api_key' key.id %}">{% csrf_token %}<button class="btn btn-sm btn-outline-danger">{% trans "Delete" %}</button></form></div>
|
||||
<code class="small user-select-all">{{ key.token }}</code>
|
||||
<div class="small text-muted">{% trans "Tags" %}: {% for tag in key.tags.all %}#{{ tag.name }} {% empty %}{% trans "all" %}{% endfor %}</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
<a class="btn btn-sm btn-outline-secondary mb-3" href="{% url 'settings' %}">← {% trans "Settings" %}</a>
|
||||
<a class="btn btn-sm btn-outline-secondary mb-3" href="{% url 'team_list' %}">← {% trans "Teams" %}</a>
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">{% trans "Team" %} {{ team.name }}</h1>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% load i18n %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">{% trans "Teams" %}</h1>
|
||||
<div class="text-muted">{% trans "All teams you belong to." %}</div>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/">← {% trans "back" %}</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<section class="col-lg-4 order-lg-2">
|
||||
<div class="card item-card shadow-sm"><div class="card-body">
|
||||
<h2 class="h5 mb-3">{% trans "Create team" %}</h2>
|
||||
<form method="post" class="vstack gap-3">
|
||||
{% csrf_token %}
|
||||
<div><label class="form-label">{% trans "Team name" %}</label>{{ team_form.name }}</div>
|
||||
<div><label class="form-label">{% trans "Team tag" %}</label>{{ team_form.slug }}<div class="small text-muted mt-1">{% trans "This tag automatically turns items into team items, e.g. #my2dos." %}</div></div>
|
||||
<button class="btn btn-dark">{% trans "Create team" %}</button>
|
||||
</form>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<section class="col-lg-8">
|
||||
<div class="vstack gap-3">
|
||||
{% for s in stats %}
|
||||
<article class="card item-card shadow-sm"><div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-2">
|
||||
<div>
|
||||
<h2 class="h5 mb-1"><a class="text-decoration-none" href="{% url 'team_detail' s.team.id %}">{{ s.team.name }}</a></h2>
|
||||
<div class="small text-muted">#{{ s.team.slug }} · {{ s.members_count }} {% trans "members" %}</div>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'team_detail' s.team.id %}">{% trans "Open team" %}</a>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<span class="badge text-bg-light">{{ s.items_count }} {% trans "items" %}</span>
|
||||
<span class="badge text-bg-warning">{{ s.open_todos_count }} {% trans "open tasks" %}</span>
|
||||
</div>
|
||||
<div class="vstack gap-2">
|
||||
{% for item in s.recent_items %}
|
||||
<a class="border rounded p-2 text-decoration-none text-dark" href="{{ item.get_absolute_url }}">
|
||||
<span class="badge text-bg-light me-1">{{ item.get_kind_display }}</span>{{ item.content|truncatechars:120 }}
|
||||
</a>
|
||||
{% empty %}
|
||||
<div class="text-muted small">{% trans "No team items yet." %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div></article>
|
||||
{% empty %}
|
||||
<div class="card item-card shadow-sm"><div class="card-body text-muted">{% trans "You are not a member of any team yet." %}</div></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -6,13 +6,17 @@ urlpatterns = [
|
||||
path('', views.index, name='index'),
|
||||
path('login/', auth_views.LoginView.as_view(template_name='core/login.html'), name='login'),
|
||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||
path('invite/<str:token>/', views.accept_user_invite, name='accept_user_invite'),
|
||||
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('language/', views.set_user_language, name='set_user_language'),
|
||||
path('settings/', views.settings_view, name='settings'),
|
||||
path('settings/api-keys/<int:pk>/delete/', views.delete_api_key, name='delete_api_key'),
|
||||
path('settings/invites/<int:pk>/delete/', views.delete_user_invite, name='delete_user_invite'),
|
||||
path('teams/', views.team_list, name='team_list'),
|
||||
path('teams/<int:pk>/', views.team_detail, name='team_detail'),
|
||||
path('teams/invite/<str:token>/', views.team_accept_invite, name='team_accept_invite'),
|
||||
path('items/<int:pk>/', views.item_detail, name='item_detail'),
|
||||
|
||||
+148
-28
@@ -2,21 +2,24 @@ import json
|
||||
import re
|
||||
from functools import wraps
|
||||
from calendar import Calendar
|
||||
from datetime import date
|
||||
from datetime import date, datetime, time
|
||||
from html import unescape
|
||||
from urllib.request import Request, urlopen
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.db.models import Case, IntegerField, Q, Value, When
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext as _
|
||||
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, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserPreferenceForm, UserSettingsForm
|
||||
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserPreference
|
||||
from .forms import ApiKeyForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm
|
||||
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
|
||||
|
||||
COMMAND_RE = re.compile(r'/(todo|note|link|journal|public|private)\b', re.I)
|
||||
URL_RE = re.compile(r'https?://\S+')
|
||||
@@ -51,6 +54,19 @@ def parse_quick_content(raw):
|
||||
return kind, visibility, content, url
|
||||
|
||||
|
||||
def parse_due_at_from_post(post):
|
||||
due_date = (post.get('due_date') or '').strip()
|
||||
due_time = (post.get('due_time') or '').strip()
|
||||
if not due_date:
|
||||
return None
|
||||
try:
|
||||
parsed_date = date.fromisoformat(due_date)
|
||||
parsed_time = time.fromisoformat(due_time) if due_time else time(23, 59)
|
||||
return timezone.make_aware(datetime.combine(parsed_date, parsed_time))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def attach_files_and_replace_tokens(item, files):
|
||||
replacements = {}
|
||||
for file in files:
|
||||
@@ -154,7 +170,9 @@ def index(request):
|
||||
response['HX-Trigger'] = 'tagsChanged'
|
||||
return response
|
||||
return redirect('index')
|
||||
tag = request.GET.get('tag')
|
||||
active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()]
|
||||
active_tag_mode = 'all' if request.GET.get('tag_mode') == 'all' else 'any'
|
||||
tag = active_tags[0] if active_tags else None
|
||||
kind = request.GET.get('kind')
|
||||
status = request.GET.get('status')
|
||||
day = request.GET.get('day')
|
||||
@@ -165,14 +183,18 @@ def index(request):
|
||||
query.pop('q', None)
|
||||
return redirect(f'{request.path}?{query.urlencode()}')
|
||||
items = visible_items(request.user)
|
||||
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||
applied_preferences = False
|
||||
if tag:
|
||||
items = items.filter(tags__name=tag)
|
||||
if active_tags:
|
||||
if active_tag_mode == 'all':
|
||||
for tag_name in active_tags:
|
||||
items = items.filter(tags__name=tag_name)
|
||||
else:
|
||||
items = items.filter(tags__name__in=active_tags).distinct()
|
||||
if kind in dict(Item.Kind.choices):
|
||||
items = items.filter(kind=kind)
|
||||
elif not any([tag, status, q, day]):
|
||||
applied_preferences = True
|
||||
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||
type_filter = Q()
|
||||
if prefs.show_notes:
|
||||
type_filter |= Q(kind=Item.Kind.NOTE)
|
||||
@@ -189,15 +211,24 @@ def index(request):
|
||||
items = items.filter(created_at__date=day)
|
||||
if status == 'open':
|
||||
items = items.filter(kind=Item.Kind.TODO, is_done=False)
|
||||
elif status == 'due':
|
||||
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59)))
|
||||
items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).order_by('due_at', '-created_at')
|
||||
elif status == 'archive':
|
||||
items = items.filter(kind=Item.Kind.TODO, is_done=True)
|
||||
elif not applied_preferences:
|
||||
items = items.exclude(kind=Item.Kind.TODO, is_done=True)
|
||||
if q:
|
||||
items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
|
||||
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59)))
|
||||
due_count = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).count()
|
||||
if prefs.due_first and status != 'archive':
|
||||
items = items.annotate(due_rank=Case(
|
||||
When(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end, then=Value(0)),
|
||||
default=Value(1), output_field=IntegerField(),
|
||||
)).order_by('due_rank', 'due_at', '-created_at')
|
||||
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, 'overview_lines': prefs.overview_lines,
|
||||
'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'active_tag': tag, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'due_count': due_count, 'overview_lines': prefs.overview_lines,
|
||||
})
|
||||
|
||||
|
||||
@@ -254,13 +285,32 @@ def journal(request):
|
||||
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)
|
||||
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||
return render(request, 'core/journal.html', {
|
||||
'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,
|
||||
})
|
||||
|
||||
|
||||
@require_POST
|
||||
def set_user_language(request):
|
||||
language = request.POST.get('language')
|
||||
next_url = request.POST.get('next') or '/'
|
||||
if language not in {'de', 'en', ''}:
|
||||
language = ''
|
||||
if request.user.is_authenticated:
|
||||
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||
prefs.language = language
|
||||
prefs.save(update_fields=['language'])
|
||||
response = redirect(next_url)
|
||||
if language:
|
||||
translation.activate(language)
|
||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
|
||||
else:
|
||||
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def settings_view(request):
|
||||
if request.method == 'POST':
|
||||
@@ -271,7 +321,7 @@ def settings_view(request):
|
||||
messages.success(request, _('Settings saved.'))
|
||||
return redirect('settings')
|
||||
elif request.POST.get('action') == 'preferences':
|
||||
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||
pref_form = UserPreferenceForm(request.POST, instance=prefs)
|
||||
if pref_form.is_valid():
|
||||
pref_form.save()
|
||||
@@ -286,24 +336,22 @@ def settings_view(request):
|
||||
key_form.save_m2m()
|
||||
messages.success(request, _('API key created: %(token)s') % {'token': api_key.token})
|
||||
return redirect('settings')
|
||||
elif request.POST.get('action') == 'team':
|
||||
team_form = TeamForm(request.POST)
|
||||
if team_form.is_valid():
|
||||
team = team_form.save(commit=False)
|
||||
team.owner = request.user
|
||||
team.save()
|
||||
TeamMembership.objects.create(team=team, user=request.user, role=TeamMembership.Role.OWNER)
|
||||
Tag.objects.get_or_create(name=team.slug)
|
||||
messages.success(request, _('Team %(team)s created. Use #%(slug)s for team items.') % {'team': team.name, 'slug': team.slug})
|
||||
return redirect('team_detail', pk=team.pk)
|
||||
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||
elif request.POST.get('action') == 'userinvite':
|
||||
invite_form = UserInviteForm(request.POST)
|
||||
if invite_form.is_valid():
|
||||
invite = invite_form.save(commit=False)
|
||||
invite.invited_by = request.user
|
||||
invite.save()
|
||||
messages.success(request, _('Invitation link created: %(url)s') % {'url': request.build_absolute_uri(reverse("accept_user_invite", args=[invite.token]))})
|
||||
return redirect('settings')
|
||||
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||
return render(request, 'core/settings.html', {
|
||||
'profile_form': UserSettingsForm(instance=request.user),
|
||||
'preference_form': UserPreferenceForm(instance=prefs),
|
||||
'key_form': ApiKeyForm(),
|
||||
'team_form': TeamForm(),
|
||||
'user_invite_form': UserInviteForm(),
|
||||
'user_invites': request.user.sent_user_invites.filter(accepted_at__isnull=True),
|
||||
'api_keys': request.user.api_keys.prefetch_related('tags'),
|
||||
'teams': Team.objects.filter(memberships__user=request.user).distinct(),
|
||||
})
|
||||
|
||||
|
||||
@@ -315,6 +363,68 @@ def delete_api_key(request, pk):
|
||||
return redirect('settings')
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_user_invite(request, pk):
|
||||
invite = get_object_or_404(UserInvite, pk=pk, invited_by=request.user, accepted_at__isnull=True)
|
||||
invite.delete()
|
||||
if request.headers.get('HX-Request'):
|
||||
return HttpResponse('')
|
||||
messages.success(request, _('Invitation deleted.'))
|
||||
return redirect('settings')
|
||||
|
||||
|
||||
def accept_user_invite(request, token):
|
||||
invite = get_object_or_404(UserInvite, token=token, accepted_at__isnull=True)
|
||||
if request.user.is_authenticated:
|
||||
invite.accepted_at = timezone.now()
|
||||
invite.save(update_fields=['accepted_at'])
|
||||
messages.success(request, _('Invitation accepted.'))
|
||||
return redirect('index')
|
||||
if request.method == 'POST':
|
||||
form = InviteRegistrationForm(request.POST)
|
||||
if form.is_valid():
|
||||
user = form.save(commit=False)
|
||||
if invite.email and not user.email:
|
||||
user.email = invite.email
|
||||
user.save()
|
||||
invite.accepted_at = timezone.now()
|
||||
invite.save(update_fields=['accepted_at'])
|
||||
login(request, user)
|
||||
messages.success(request, _('Welcome to my2dos.'))
|
||||
return redirect('index')
|
||||
else:
|
||||
form = InviteRegistrationForm(initial={'email': invite.email})
|
||||
return render(request, 'core/accept_user_invite.html', {'invite': invite, 'form': form})
|
||||
|
||||
|
||||
@login_required
|
||||
def team_list(request):
|
||||
if request.method == 'POST':
|
||||
team_form = TeamForm(request.POST)
|
||||
if team_form.is_valid():
|
||||
team = team_form.save(commit=False)
|
||||
team.owner = request.user
|
||||
team.save()
|
||||
TeamMembership.objects.create(team=team, user=request.user, role=TeamMembership.Role.OWNER)
|
||||
Tag.objects.get_or_create(name=team.slug)
|
||||
messages.success(request, _('Team %(team)s created. Use #%(slug)s for team items.') % {'team': team.name, 'slug': team.slug})
|
||||
return redirect('team_detail', pk=team.pk)
|
||||
teams = Team.objects.filter(memberships__user=request.user).distinct().prefetch_related('memberships__user')
|
||||
stats = []
|
||||
base_items = visible_items(request.user)
|
||||
for team in teams:
|
||||
team_items = base_items.filter(team=team)
|
||||
stats.append({
|
||||
'team': team,
|
||||
'members_count': team.memberships.count(),
|
||||
'items_count': team_items.count(),
|
||||
'open_todos_count': team_items.filter(kind=Item.Kind.TODO, is_done=False).count(),
|
||||
'recent_items': team_items[:3],
|
||||
})
|
||||
return render(request, 'core/team_list.html', {'stats': stats, 'team_form': TeamForm(), 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
|
||||
|
||||
|
||||
@login_required
|
||||
def team_detail(request, pk):
|
||||
membership = get_object_or_404(TeamMembership.objects.select_related('team'), team_id=pk, user=request.user)
|
||||
@@ -384,7 +494,11 @@ def edit_item(request, pk):
|
||||
if request.method == 'POST':
|
||||
form = EditItemForm(request.POST, request.FILES, instance=item)
|
||||
if form.is_valid():
|
||||
item = form.save()
|
||||
item = form.save(commit=False)
|
||||
if item.kind == Item.Kind.TODO:
|
||||
item.due_at = parse_due_at_from_post(request.POST)
|
||||
item.save()
|
||||
form.save_m2m()
|
||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||
item.sync_metadata()
|
||||
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||
@@ -403,7 +517,11 @@ def item_editor(request, pk):
|
||||
if request.method == 'POST':
|
||||
form = EditItemForm(request.POST, request.FILES, instance=item)
|
||||
if form.is_valid():
|
||||
item = form.save()
|
||||
item = form.save(commit=False)
|
||||
if item.kind == Item.Kind.TODO:
|
||||
item.due_at = parse_due_at_from_post(request.POST)
|
||||
item.save()
|
||||
form.save_m2m()
|
||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||
item.sync_metadata()
|
||||
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||
@@ -468,7 +586,9 @@ def kanban_move(request, pk, item_pk):
|
||||
|
||||
@login_required
|
||||
def tag_list(request):
|
||||
return render(request, 'core/_tags.html', {'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')})
|
||||
active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()]
|
||||
active_tag_mode = 'all' if request.GET.get('tag_mode') == 'all' else 'any'
|
||||
return render(request, 'core/_tags.html', {'tags': Tag.objects.all(), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode})
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
Reference in New Issue
Block a user