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'(?