From c409fbc4416073b8b3963fd509230fb64a8f6da4 Mon Sep 17 00:00:00 2001 From: rucki Date: Sun, 23 Aug 2026 14:55:04 +0200 Subject: [PATCH] Add teams and internationalization --- core/admin.py | 9 +- core/forms.py | 45 +- ...lity_team_item_team_teaminvite_and_more.py | 67 +++ ..._kanban_status_alter_item_kind_and_more.py | 28 + core/models.py | 72 ++- core/templates/core/_edit_form.html | 23 +- core/templates/core/_item.html | 12 +- core/templates/core/_tags.html | 4 +- core/templates/core/base.html | 15 +- core/templates/core/detail.html | 3 +- core/templates/core/index.html | 65 +-- core/templates/core/item_editor.html | 19 +- core/templates/core/journal.html | 14 +- core/templates/core/kanban_detail.html | 5 +- core/templates/core/kanban_list.html | 7 +- core/templates/core/landing.html | 59 ++ core/templates/core/login.html | 9 +- core/templates/core/new_item.html | 19 +- core/templates/core/settings.html | 45 +- core/templates/core/team_detail.html | 40 ++ core/urls.py | 2 + core/views.py | 137 ++++- locale/de/LC_MESSAGES/django.po | 517 ++++++++++++++++++ locale/en/LC_MESSAGES/django.po | 506 +++++++++++++++++ my2dos/settings.py | 8 +- my2dos/urls.py | 1 + 26 files changed, 1582 insertions(+), 149 deletions(-) create mode 100644 core/migrations/0010_alter_item_visibility_team_item_team_teaminvite_and_more.py create mode 100644 core/migrations/0011_alter_item_kanban_status_alter_item_kind_and_more.py create mode 100644 core/templates/core/landing.html create mode 100644 core/templates/core/team_detail.html create mode 100644 locale/de/LC_MESSAGES/django.po create mode 100644 locale/en/LC_MESSAGES/django.po diff --git a/core/admin.py b/core/admin.py index f54db88..615ca03 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import ApiKey, Attachment, Item, Kanban, Tag, UserPreference +from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserPreference class AttachmentInline(admin.TabularInline): @@ -9,8 +9,8 @@ class AttachmentInline(admin.TabularInline): @admin.register(Item) class ItemAdmin(admin.ModelAdmin): - list_display = ('id', 'kind', 'visibility', 'is_done', 'owner', 'created_at') - list_filter = ('kind', 'visibility', 'is_done', 'tags') + list_display = ('id', 'kind', 'visibility', 'team', 'is_done', 'owner', 'created_at') + list_filter = ('kind', 'visibility', 'team', 'is_done', 'tags') search_fields = ('content', 'url') inlines = [AttachmentInline] @@ -22,5 +22,8 @@ class ApiKeyAdmin(admin.ModelAdmin): admin.site.register(Tag) +admin.site.register(Team) +admin.site.register(TeamMembership) +admin.site.register(TeamInvite) admin.site.register(Kanban) admin.site.register(UserPreference) diff --git a/core/forms.py b/core/forms.py index 74fb69e..283eb90 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.auth.models import User -from .models import ApiKey, Attachment, Item, Kanban, Tag, UserPreference +from django.utils.translation import gettext_lazy as _ +from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, UserPreference class MultipleFileInput(forms.ClearableFileInput): @@ -24,7 +25,7 @@ class QuickItemForm(forms.ModelForm): widgets = {'content': forms.Textarea(attrs={ 'class': 'form-control form-control-lg', 'rows': 3, - 'placeholder': 'Schreibe etwas … /todo /link /public #tag [[note:1]]', + 'placeholder': _('Write something … /todo /link /public #tag [[note:1]]'), 'x-model': 'text', '@keydown': 'onKeydown($event)', '@input': 'onInput($event)', @@ -61,23 +62,45 @@ class UserPreferenceForm(forms.ModelForm): model = UserPreference fields = ['show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'show_journal', 'overview_lines'] labels = { - 'show_notes': 'Notizen anzeigen', - 'show_open_todos': 'Offene Aufgaben anzeigen', - 'show_done_todos': 'Erledigte Aufgaben anzeigen', - 'show_links': 'Links anzeigen', - 'show_journal': 'Journal anzeigen', - 'overview_lines': 'Zeilen in der Übersicht', + 'show_notes': _('Show notes'), + 'show_open_todos': _('Show open tasks'), + 'show_done_todos': _('Show completed tasks'), + 'show_links': _('Show links'), + 'show_journal': _('Show journal'), + 'overview_lines': _('Lines in overview'), } widgets = {'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50})} +class TeamForm(forms.ModelForm): + class Meta: + model = Team + fields = ['name', 'slug'] + labels = {'name': _('Team name'), 'slug': _('Team tag')} + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'}), + 'slug': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'}), + } + + def clean_slug(self): + return self.cleaned_data['slug'].strip().lstrip('#').lower() + + +class TeamInviteForm(forms.ModelForm): + class Meta: + model = TeamInvite + fields = ['email'] + labels = {'email': _('Email')} + widgets = {'email': forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'person@example.com'})} + + class KanbanForm(forms.ModelForm): - tag_name = forms.CharField(label='Tag', widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'})) + 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'})} + widgets = {'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Kanban name')})} def save(self, commit=True): tag_name = self.cleaned_data['tag_name'].strip().lstrip('#').lower() @@ -91,7 +114,7 @@ class ApiKeyForm(forms.ModelForm): model = ApiKey fields = ['name', 'tags'] widgets = { - 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Name des API Keys'}), + 'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('API key name')}), 'tags': forms.CheckboxSelectMultiple(), } diff --git a/core/migrations/0010_alter_item_visibility_team_item_team_teaminvite_and_more.py b/core/migrations/0010_alter_item_visibility_team_item_team_teaminvite_and_more.py new file mode 100644 index 0000000..cb7017a --- /dev/null +++ b/core/migrations/0010_alter_item_visibility_team_item_team_teaminvite_and_more.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.17 on 2026-08-23 09:28 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_item_kanban_status'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='item', + name='visibility', + field=models.CharField(choices=[('private', 'Privat'), ('team', 'Team'), ('public', 'Öffentlich')], default='private', max_length=10), + ), + migrations.CreateModel( + name='Team', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120)), + ('slug', models.SlugField(allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos', max_length=80, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_teams', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='item', + name='team', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='items', to='core.team'), + ), + migrations.CreateModel( + name='TeamInvite', + 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_team_invites', to=settings.AUTH_USER_MODEL)), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='core.team')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='TeamMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Mitglied')], default='member', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='core.team')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('team', 'user')}, + }, + ), + ] diff --git a/core/migrations/0011_alter_item_kanban_status_alter_item_kind_and_more.py b/core/migrations/0011_alter_item_kanban_status_alter_item_kind_and_more.py new file mode 100644 index 0000000..cfe2470 --- /dev/null +++ b/core/migrations/0011_alter_item_kanban_status_alter_item_kind_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.17 on 2026-08-23 12:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0010_alter_item_visibility_team_item_team_teaminvite_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='item', + name='kanban_status', + field=models.CharField(choices=[('inbox', 'Inbox'), ('todo', 'Todo'), ('progress', 'In Progress'), ('done', 'Done')], default='inbox', max_length=20), + ), + migrations.AlterField( + model_name='item', + name='kind', + field=models.CharField(choices=[('todo', 'Task'), ('note', 'Note'), ('link', 'Link'), ('journal', 'Journal')], default='note', max_length=10), + ), + migrations.AlterField( + model_name='item', + name='visibility', + field=models.CharField(choices=[('private', 'Private'), ('team', 'Team'), ('public', 'Public')], default='private', max_length=10), + ), + ] diff --git a/core/models.py b/core/models.py index b8b2a77..07b77c2 100644 --- a/core/models.py +++ b/core/models.py @@ -3,6 +3,7 @@ import secrets from django.conf import settings from django.db import models from django.urls import reverse +from django.utils.translation import gettext_lazy as _ TAG_RE = re.compile(r'(? {% csrf_token %}
-
Typ: {{ item.get_kind_display }}
+
{% trans "Type:" %} {{ item.get_kind_display }}
- +
-
Tag auswählen
{% for tag in tags %}{% endfor %}
+
{% trans "Choose tag" %}
{% for tag in tags %}{% endfor %}
- +
- {% if item.kind == 'todo' %}
{% endif %} + {% if item.kind == 'todo' %}
{% endif %}
{% if item.kind == 'link' %}{% endif %} - +
- - Im Editor öffnen - + + {% trans "Open in editor" %} +
diff --git a/core/templates/core/_item.html b/core/templates/core/_item.html index 9a47e77..e5fc7c3 100644 --- a/core/templates/core/_item.html +++ b/core/templates/core/_item.html @@ -1,24 +1,24 @@ -{% load markdown_extras %} +{% load markdown_extras i18n %}
{{ item.get_kind_display }} - {{ item.get_visibility_display }} + {% if item.team %}{{ item.team.name }}{% else %}{{ item.get_visibility_display }}{% endif %} #{{ item.id }} {% for tag in item.tags.all %}#{{ tag.name }}{% endfor %}
{{ item|item_markdown }}
- {% if overview_lines %}{% endif %} + {% if overview_lines %}{% endif %}
{% if item.comment %}
{{ item.comment|markdown }}
{% endif %} - {% if item.due_at %}
Fällig: {{ item.due_at|date:'d.m.Y H:i' }}
{% endif %} + {% if item.due_at %}
{% trans "Due:" %} {{ item.due_at|date:'d.m.Y H:i' }}
{% endif %} {% if item.url %}{{ item.url }}{% endif %} - {% if item.linked_items.all %}
Links: {% for linked in item.linked_items.all %}#{{ linked.id }} {% endfor %}
{% endif %} + {% if item.linked_items.all %}
{% trans "Links:" %} {% for linked in item.linked_items.all %}#{{ linked.id }} {% endfor %}
{% endif %} {% if item.attachments.all %} -
Anhänge: {% for a in item.attachments.all %}{{ a.file.name }} {% endfor %}
+
{% trans "Attachments:" %} {% for a in item.attachments.all %}{{ a.file.name }} {% endfor %}
{% for a in item.attachments.all %}{% if a.file.name|is_image and not a|is_embedded:item %}{{ a.file.name }}{% endif %}{% endfor %}
diff --git a/core/templates/core/_tags.html b/core/templates/core/_tags.html index 7cfe232..b3f98dd 100644 --- a/core/templates/core/_tags.html +++ b/core/templates/core/_tags.html @@ -1,4 +1,4 @@ -{% load querystring %} +{% load querystring i18n %}
{% for tag in tags %} {% if active_tag == tag.name %} @@ -6,5 +6,5 @@ {% else %} #{{ tag.name }} {% endif %} - {% empty %}Noch keine Tags{% endfor %} + {% empty %}{% trans "No tags yet" %}{% endfor %}
diff --git a/core/templates/core/base.html b/core/templates/core/base.html index 882440a..6967eff 100644 --- a/core/templates/core/base.html +++ b/core/templates/core/base.html @@ -1,4 +1,4 @@ -{% load static %} +{% load static i18n %} @@ -21,7 +21,7 @@ - +
{% for message in messages %}
{{ message }}
{% endfor %}{% block content %}{% endblock %}