Improve teams settings invites and due tasks
This commit is contained in:
+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