Add teams and internationalization

This commit is contained in:
rucki
2026-08-23 14:55:04 +02:00
parent 218d841d7f
commit c409fbc441
26 changed files with 1582 additions and 149 deletions
+121 -16
View File
@@ -9,12 +9,14 @@ 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.urls import reverse
from django.utils import timezone
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, UserPreferenceForm, UserSettingsForm
from .models import ApiKey, Attachment, Item, Kanban, Tag, UserPreference
from .forms import ApiKeyForm, EditItemForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserPreferenceForm, UserSettingsForm
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserPreference
COMMAND_RE = re.compile(r'/(todo|note|link|journal|public|private)\b', re.I)
URL_RE = re.compile(r'https?://\S+')
@@ -66,8 +68,14 @@ def attach_files_and_replace_tokens(item, files):
item.save(update_fields=['content', 'comment', 'updated_at'])
def user_team_ids(user):
if not user.is_authenticated:
return []
return list(user.team_memberships.values_list('team_id', flat=True))
def visible_items(user, api_key=None):
qs = Item.objects.select_related('owner').prefetch_related('tags', 'attachments', 'linked_items')
qs = Item.objects.select_related('owner', 'team').prefetch_related('tags', 'attachments', 'linked_items')
if api_key:
qs = qs.filter(owner=api_key.owner)
allowed_tags = api_key.tags.all()
@@ -75,10 +83,38 @@ def visible_items(user, api_key=None):
qs = qs.filter(tags__in=allowed_tags).distinct()
return qs
if user.is_authenticated:
return qs.filter(Q(owner=user) | Q(visibility=Item.Visibility.PUBLIC))
return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
return qs.filter(visibility=Item.Visibility.PUBLIC)
def user_can_edit_item(user, item):
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user).exists())
def user_can_delete_item(user, item):
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists())
def apply_team_from_tags(item, user, force_private=False):
if force_private:
item.team = None
if item.visibility == Item.Visibility.TEAM:
item.visibility = Item.Visibility.PRIVATE
item.save(update_fields=['team', 'visibility', 'updated_at'])
return
tag_names = list(item.tags.values_list('name', flat=True))
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
if membership:
item.team = membership.team
if item.visibility == Item.Visibility.PRIVATE:
item.visibility = Item.Visibility.TEAM
else:
item.team = None
if item.visibility == Item.Visibility.TEAM:
item.visibility = Item.Visibility.PRIVATE
item.save(update_fields=['team', 'visibility', 'updated_at'])
def get_api_key(request):
token = request.headers.get('X-Api-Key') or request.headers.get('Authorization', '').removeprefix('Bearer ').strip()
if not token:
@@ -96,18 +132,23 @@ def api_auth_required(view):
return wrapper
@login_required
def index(request):
if not request.user.is_authenticated:
if request.method == 'POST':
return redirect('login')
return render(request, 'core/landing.html')
if request.method == 'POST':
form = QuickItemForm(request.POST, request.FILES)
if form.is_valid():
kind, visibility, content, url = parse_quick_content(form.cleaned_data['content'])
raw = form.cleaned_data['content']
kind, visibility, content, url = parse_quick_content(raw)
active_tag = request.GET.get('tag')
if active_tag and f'#{active_tag.lower()}' not in content.lower():
content = f'{content} #{active_tag}'
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()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
if request.headers.get('HX-Request'):
response = render(request, 'core/_create_response.html', {'item': item, 'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')})
response['HX-Trigger'] = 'tagsChanged'
@@ -174,6 +215,7 @@ def new_item(request):
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()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
return redirect(next_url)
return render(request, 'core/new_item.html', {
'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url,
@@ -192,6 +234,7 @@ def journal(request):
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()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
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')
@@ -225,14 +268,14 @@ def settings_view(request):
form = UserSettingsForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
messages.success(request, 'Einstellungen gespeichert.')
messages.success(request, _('Settings saved.'))
return redirect('settings')
elif request.POST.get('action') == 'preferences':
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
pref_form = UserPreferenceForm(request.POST, instance=prefs)
if pref_form.is_valid():
pref_form.save()
messages.success(request, 'Standardansicht gespeichert.')
messages.success(request, _('Default view saved.'))
return redirect('settings')
elif request.POST.get('action') == 'apikey':
key_form = ApiKeyForm(request.POST)
@@ -241,14 +284,26 @@ def settings_view(request):
api_key.owner = request.user
api_key.save()
key_form.save_m2m()
messages.success(request, f'API Key erzeugt: {api_key.token}')
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)
return render(request, 'core/settings.html', {
'profile_form': UserSettingsForm(instance=request.user),
'preference_form': UserPreferenceForm(instance=prefs),
'key_form': ApiKeyForm(),
'team_form': TeamForm(),
'api_keys': request.user.api_keys.prefetch_related('tags'),
'teams': Team.objects.filter(memberships__user=request.user).distinct(),
})
@@ -260,6 +315,41 @@ def delete_api_key(request, pk):
return redirect('settings')
@login_required
def team_detail(request, pk):
membership = get_object_or_404(TeamMembership.objects.select_related('team'), team_id=pk, user=request.user)
team = membership.team
if request.method == 'POST' and membership.role in [TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]:
invite_form = TeamInviteForm(request.POST)
if invite_form.is_valid():
invite = invite_form.save(commit=False)
invite.team = team
invite.invited_by = request.user
invite.save()
messages.success(request, _('Invitation link created: %(url)s') % {'url': request.build_absolute_uri(reverse("team_accept_invite", args=[invite.token]))})
return redirect('team_detail', pk=team.pk)
items = visible_items(request.user).filter(team=team)
return render(request, 'core/team_detail.html', {
'team': team,
'membership': membership,
'memberships': team.memberships.select_related('user'),
'invites': team.invites.filter(accepted_at__isnull=True),
'invite_form': TeamInviteForm(),
'items': items,
'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4,
})
@login_required
def team_accept_invite(request, token):
invite = get_object_or_404(TeamInvite.objects.select_related('team'), token=token, accepted_at__isnull=True)
TeamMembership.objects.get_or_create(team=invite.team, user=request.user, defaults={'role': TeamMembership.Role.MEMBER})
invite.accepted_at = timezone.now()
invite.save(update_fields=['accepted_at'])
messages.success(request, _('You are now a member of team %(team)s.') % {'team': invite.team.name})
return redirect('team_detail', pk=invite.team.pk)
@login_required
def item_detail(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk)
@@ -269,7 +359,9 @@ def item_detail(request, pk):
@login_required
@require_POST
def toggle_done(request, pk):
item = get_object_or_404(Item, pk=pk, owner=request.user, kind=Item.Kind.TODO)
item = get_object_or_404(visible_items(request.user), pk=pk, kind=Item.Kind.TODO)
if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
item.is_done = not item.is_done
item.completed_at = timezone.now() if item.is_done else None
item.save(update_fields=['is_done', 'completed_at', 'updated_at'])
@@ -286,13 +378,16 @@ def item_card(request, pk):
@login_required
def edit_item(request, pk):
item = get_object_or_404(Item, pk=pk, owner=request.user)
item = get_object_or_404(visible_items(request.user), pk=pk)
if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
if request.method == 'POST':
form = EditItemForm(request.POST, request.FILES, instance=item)
if form.is_valid():
item = form.save()
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)
response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
response['HX-Trigger'] = 'tagsChanged'
return response
@@ -301,7 +396,9 @@ def edit_item(request, pk):
@login_required
def item_editor(request, pk):
item = get_object_or_404(Item, pk=pk, owner=request.user)
item = get_object_or_404(visible_items(request.user), pk=pk)
if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
next_url = request.GET.get('next') or item.get_absolute_url()
if request.method == 'POST':
form = EditItemForm(request.POST, request.FILES, instance=item)
@@ -309,6 +406,7 @@ def item_editor(request, pk):
item = form.save()
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)
return redirect(next_url)
return render(request, 'core/item_editor.html', {'item': item, 'tags': Tag.objects.all(), 'next_url': next_url})
@@ -316,7 +414,9 @@ def item_editor(request, pk):
@login_required
@require_POST
def delete_item(request, pk):
item = get_object_or_404(Item, pk=pk, owner=request.user)
item = get_object_or_404(visible_items(request.user), pk=pk)
if not user_can_delete_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
item.delete()
return JsonResponse({'ok': True})
@@ -378,7 +478,9 @@ def api_tags(request):
@login_required
def api_attachment_suggestions(request):
item = get_object_or_404(Item, pk=request.GET.get('item'), owner=request.user)
item = get_object_or_404(visible_items(request.user), pk=request.GET.get('item'))
if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
data = [{'id': a.id, 'name': a.file.name.split('/')[-1], 'url': a.file.url} for a in item.attachments.all()]
return JsonResponse({'attachments': data})
@@ -400,6 +502,7 @@ def serialize_item(item):
'content': item.content,
'comment': item.comment,
'visibility': item.visibility,
'team': item.team.slug if item.team else None,
'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,
@@ -424,6 +527,7 @@ def api_items(request):
kind, visibility, content, url = parse_quick_content(raw)
item = Item.objects.create(owner=user, kind=data.get('kind') or kind, visibility=data.get('visibility') or visibility, content=content, url=data.get('url') or url)
item.sync_metadata()
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I)))
return JsonResponse(serialize_item(item), status=201)
@@ -436,11 +540,11 @@ def api_item_detail(request, pk):
if request.method == 'GET':
return JsonResponse(serialize_item(item))
if request.method == 'DELETE':
if item.owner != user:
if not user_can_delete_item(user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
item.delete()
return JsonResponse({'ok': True})
if item.owner != user:
if not user_can_edit_item(user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
data = json.loads(request.body or '{}')
for field in ['content', 'comment', 'kind', 'visibility', 'is_done', 'due_at', 'url']:
@@ -450,4 +554,5 @@ def api_item_detail(request, pk):
item.completed_at = timezone.now() if item.is_done else None
item.save()
item.sync_metadata()
apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
return JsonResponse(serialize_item(item))