From d7472554df03ccb980e1ecf4eaecb6b2c284993f Mon Sep 17 00:00:00 2001 From: rucki Date: Fri, 11 Sep 2026 12:19:38 +0200 Subject: [PATCH] Add workspace due indicators and todo assignment --- core/context_processors.py | 15 ++++++- core/migrations/0023_item_assignee.py | 21 +++++++++ core/models.py | 1 + core/templates/core/_item.html | 1 + core/templates/core/base.html | 2 +- core/tests.py | 64 +++++++++++++++++++++++++++ core/views.py | 38 ++++++++++++++-- 7 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 core/migrations/0023_item_assignee.py diff --git a/core/context_processors.py b/core/context_processors.py index 565101b..51e3f7d 100644 --- a/core/context_processors.py +++ b/core/context_processors.py @@ -1,7 +1,11 @@ +from datetime import datetime, time + +from django.db.models import Q from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext as _ -from .models import UserPreference, WorkspaceMembership, get_or_create_personal_workspace +from .models import Item, UserPreference, WorkspaceMembership, get_or_create_personal_workspace def active_workspace_navigation(request): @@ -13,9 +17,16 @@ def active_workspace_navigation(request): membership = WorkspaceMembership.objects.select_related('workspace').filter(user=request.user, workspace_id=workspace_id).first() if membership: workspace = membership.workspace + memberships = list(WorkspaceMembership.objects.filter(user=request.user).select_related('workspace').order_by('workspace__name')) + today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59))) + for membership in memberships: + team_ids = request.user.team_memberships.filter(team__workspace=membership.workspace).values_list('team_id', flat=True) + membership.due_count = Item.objects.filter( + workspace=membership.workspace, kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end, + ).filter(Q(owner=request.user) | Q(assignee=request.user) | Q(team_id__in=team_ids) | Q(visibility=Item.Visibility.PUBLIC)).distinct().count() return { 'active_workspace': workspace, - 'workspace_memberships': WorkspaceMembership.objects.filter(user=request.user).select_related('workspace').order_by('workspace__name'), + 'workspace_memberships': memberships, } diff --git a/core/migrations/0023_item_assignee.py b/core/migrations/0023_item_assignee.py new file mode 100644 index 0000000..1c0a95e --- /dev/null +++ b/core/migrations/0023_item_assignee.py @@ -0,0 +1,21 @@ +# Generated by Pi coding agent + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0022_workspace_tenancy'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='assignee', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_items', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/core/models.py b/core/models.py index 2aa89a3..45a1738 100644 --- a/core/models.py +++ b/core/models.py @@ -187,6 +187,7 @@ class Item(models.Model): workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='items') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='items') + assignee = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True, related_name='assigned_items') team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='items') kind = models.CharField(max_length=10, choices=Kind.choices, default=Kind.NOTE) content = models.TextField() diff --git a/core/templates/core/_item.html b/core/templates/core/_item.html index d5d49dd..aa9b8f2 100644 --- a/core/templates/core/_item.html +++ b/core/templates/core/_item.html @@ -8,6 +8,7 @@ {{ item.get_kind_display }} {% if item.team %}{{ item.team.name }}{% else %}{{ item.get_visibility_display }}{% endif %} #{{ item.id }} + {% if item.assignee %}@{{ item.assignee.username }}{% endif %} {% for tag in item.tags.all %}#{{ tag.name }}{% endfor %}
diff --git a/core/templates/core/base.html b/core/templates/core/base.html index e2e72dd..001b7fe 100644 --- a/core/templates/core/base.html +++ b/core/templates/core/base.html @@ -32,7 +32,7 @@ {% if user.is_authenticated %} diff --git a/core/tests.py b/core/tests.py index 5ebd4a8..f3ccdea 100644 --- a/core/tests.py +++ b/core/tests.py @@ -104,6 +104,70 @@ class WorkspaceTenancyTests(TestCase): self.client.force_login(outsider) self.assertNotContains(self.client.get(reverse('index')), 'Workspace public') + def test_workspace_switcher_shows_due_counts_for_other_workspaces(self): + user = get_user_model().objects.create_user(username='workspace-due', password='secret') + personal = get_or_create_personal_workspace(user) + company = Workspace.objects.create(owner=user, name='Company Due', slug='company-due') + WorkspaceMembership.objects.create(workspace=company, user=user, role=WorkspaceMembership.Role.OWNER) + due = timezone.make_aware(datetime.combine(timezone.localdate(), time(12, 0))) + Item.objects.create(workspace=company, owner=user, kind=Item.Kind.TODO, content='Due elsewhere', due_at=due) + self.client.force_login(user) + self.client.post(reverse('workspace_switch', args=[personal.id]), {'next': reverse('index')}) + + response = self.client.get(reverse('index')) + + self.assertContains(response, 'Company Due') + self.assertContains(response, 'text-bg-danger">1') + self.assertNotContains(response, 'Due elsewhere') + + def test_todo_can_be_assigned_to_workspace_member_with_mention(self): + owner = get_user_model().objects.create_user(username='assign-owner', password='secret') + assignee = get_user_model().objects.create_user(username='alice', password='secret') + workspace = Workspace.objects.create(owner=owner, name='Assign Co', slug='assign-co') + WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER) + WorkspaceMembership.objects.create(workspace=workspace, user=assignee, role=WorkspaceMembership.Role.MEMBER) + self.client.force_login(owner) + self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')}) + + response = self.client.post(reverse('index'), {'content': '/todo Prepare report @alice'}) + + self.assertRedirects(response, reverse('index')) + item = Item.objects.get(content='Prepare report @alice') + self.assertEqual(item.workspace, workspace) + self.assertEqual(item.assignee, assignee) + + def test_assigned_private_todo_is_visible_to_assignee(self): + owner = get_user_model().objects.create_user(username='assign-owner2', password='secret') + assignee = get_user_model().objects.create_user(username='bob', password='secret') + workspace = Workspace.objects.create(owner=owner, name='Assign Co 2', slug='assign-co-2') + WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER) + WorkspaceMembership.objects.create(workspace=workspace, user=assignee, role=WorkspaceMembership.Role.MEMBER) + Item.objects.create(workspace=workspace, owner=owner, assignee=assignee, kind=Item.Kind.TODO, content='Assigned private') + self.client.force_login(assignee) + self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')}) + + response = self.client.get(reverse('index')) + + self.assertContains(response, 'Assigned private') + self.assertContains(response, '@bob') + + def test_team_todo_assignment_requires_team_membership(self): + owner = get_user_model().objects.create_user(username='assign-owner3', password='secret') + non_team_member = get_user_model().objects.create_user(username='charlie', password='secret') + workspace = Workspace.objects.create(owner=owner, name='Assign Co 3', slug='assign-co-3') + WorkspaceMembership.objects.create(workspace=workspace, user=owner, role=WorkspaceMembership.Role.OWNER) + WorkspaceMembership.objects.create(workspace=workspace, user=non_team_member, role=WorkspaceMembership.Role.MEMBER) + team = Team.objects.create(workspace=workspace, owner=owner, name='Ops', slug='ops') + TeamMembership.objects.create(team=team, user=owner, role=TeamMembership.Role.OWNER) + self.client.force_login(owner) + self.client.post(reverse('workspace_switch', args=[workspace.id]), {'next': reverse('index')}) + + self.client.post(reverse('index'), {'content': '/todo Team task @charlie #ops'}) + + item = Item.objects.get(content='Team task @charlie #ops') + self.assertEqual(item.team, team) + self.assertIsNone(item.assignee) + def test_authenticated_overview_is_limited_to_current_workspace(self): user = get_user_model().objects.create_user(username='tenant-visible', password='secret') other = get_user_model().objects.create_user(username='tenant-other', password='secret') diff --git a/core/views.py b/core/views.py index 29a214e..3967540 100644 --- a/core/views.py +++ b/core/views.py @@ -7,7 +7,7 @@ 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 import get_user_model, login from django.contrib.auth.decorators import login_required from django.db import transaction from django.db.models import Case, Count, IntegerField, ProtectedError, Q, Value, When @@ -32,6 +32,7 @@ FIRST_LINE_URL_RE = re.compile(r'^(https?://\S+)[ \t]*(?:\r?\n|$)') TITLE_RE = re.compile(r']*>(.*?)', re.I | re.S) DATE_COMMAND_RE = re.compile(r'/datum\s+(\d{1,2}\.\d{1,2}\.(?:\d{2}|\d{4}))(?:\s+(\d{1,2}:\d{2}))?\b', re.I) RELATIVE_DATE_COMMAND_RE = re.compile(r'/(morgen|übermorgen|uebermorgen|nächstewoche|naechstewoche)\b', re.I) +MENTION_RE = re.compile(r'(?