Add teams and internationalization
This commit is contained in:
+121
-16
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user