Add workspace due indicators and todo assignment
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<span class="badge {% if item.kind == 'todo' %}text-bg-primary{% elif item.kind == 'link' %}text-bg-success{% elif item.kind == 'journal' %}text-bg-info{% else %}text-bg-secondary{% endif %}">{{ item.get_kind_display }}</span>
|
||||
{% if item.team %}<a class="badge text-bg-warning text-decoration-none" href="{% url 'team_detail' item.team.id %}">{{ item.team.name }}</a>{% else %}<span class="badge text-bg-light">{{ item.get_visibility_display }}</span>{% endif %}
|
||||
<a class="text-muted small" href="{{ item.get_absolute_url }}?next={{ request.get_full_path|urlencode }}">#{{ item.id }}</a>
|
||||
{% if item.assignee %}<span class="badge text-bg-light">@{{ item.assignee.username }}</span>{% endif %}
|
||||
{% for tag in item.tags.all %}<a class="badge rounded-pill text-bg-light text-decoration-none tag" href="/?tag={{ tag.name }}">#{{ tag.name }}</a>{% endfor %}
|
||||
</div>
|
||||
<div x-data="{expanded:false,showMore:false,check(){this.$nextTick(()=>{let el=this.$refs.content;this.showMore=el && el.scrollHeight > el.clientHeight + 2})}}" x-init="check()" class="position-relative">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
{% if user.is_authenticated %}
|
||||
<div class="dropdown ms-auto"><button class="btn btn-sm btn-outline-secondary dropdown-toggle user-menu-btn" data-bs-toggle="dropdown">{{ active_workspace.name|default:user.get_full_name|default:user.username }}</button><ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><h6 class="dropdown-header">{% trans "Workspace" %}</h6></li>
|
||||
{% for membership in workspace_memberships %}<li><form method="post" action="{% url 'workspace_switch' membership.workspace.id %}">{% csrf_token %}<input type="hidden" name="next" value="{{ request.get_full_path }}"><button class="dropdown-item {% if active_workspace.id == membership.workspace.id %}active{% endif %}">{% if active_workspace.id == membership.workspace.id %}✓ {% endif %}{{ membership.workspace.name }}</button></form></li>{% endfor %}
|
||||
{% for membership in workspace_memberships %}<li><form method="post" action="{% url 'workspace_switch' membership.workspace.id %}">{% csrf_token %}<input type="hidden" name="next" value="{{ request.get_full_path }}"><button class="dropdown-item d-flex justify-content-between gap-2 {% if active_workspace.id == membership.workspace.id %}active{% endif %}"><span>{% if active_workspace.id == membership.workspace.id %}✓ {% endif %}{{ membership.workspace.name }}</span>{% if membership.due_count %}<span class="badge text-bg-danger">{{ membership.due_count }}</span>{% endif %}</button></form></li>{% endfor %}
|
||||
<li><a class="dropdown-item" href="{% url 'settings' %}#workspaces">+ {% trans "Create workspace" %}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'settings' %}">{% trans "Settings" %}</a></li><li><button id="pwa-install" type="button" class="dropdown-item d-none">{% trans "Install app" %}</button></li>{% if user.is_staff %}<li><a class="dropdown-item" href="/admin/">Admin</a></li>{% endif %}<li><hr class="dropdown-divider"></li><li><form method="post" action="{% url 'logout' %}">{% csrf_token %}<button class="dropdown-item">{% trans "Logout" %}</button></form></li></ul></div>
|
||||
|
||||
@@ -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</span>')
|
||||
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')
|
||||
|
||||
+34
-4
@@ -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'<title[^>]*>(.*?)</title>', 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'(?<!\w)@([\w.@+-]+)')
|
||||
CODE_FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})')
|
||||
INLINE_CODE_RE = re.compile(r'(`+).*?\1', re.S)
|
||||
|
||||
@@ -228,7 +229,7 @@ def user_team_ids(user, workspace=None):
|
||||
|
||||
def visible_items(user, api_key=None, workspace=None):
|
||||
workspace = current_workspace(user, api_key, workspace)
|
||||
qs = Item.objects.select_related('owner', 'team', 'workspace').prefetch_related('tags', 'attachments', 'linked_items')
|
||||
qs = Item.objects.select_related('owner', 'assignee', 'team', 'workspace').prefetch_related('tags', 'attachments', 'linked_items')
|
||||
if workspace:
|
||||
qs = qs.filter(workspace=workspace)
|
||||
elif not user.is_authenticated:
|
||||
@@ -240,12 +241,12 @@ def visible_items(user, api_key=None, workspace=None):
|
||||
qs = qs.filter(tags__in=allowed_tags).distinct()
|
||||
return qs
|
||||
if user.is_authenticated:
|
||||
return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user, workspace)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
|
||||
return qs.filter(Q(owner=user) | Q(assignee=user) | Q(team_id__in=user_team_ids(user, workspace)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
|
||||
return qs.none()
|
||||
|
||||
|
||||
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())
|
||||
return item.owner_id == user.id or item.assignee_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user).exists())
|
||||
|
||||
|
||||
def visible_tags(user, workspace=None):
|
||||
@@ -282,6 +283,26 @@ def item_scope_fits_collections(item, visibility, team):
|
||||
) for section in sections)
|
||||
|
||||
|
||||
def assign_item_from_mentions(item):
|
||||
if item.kind != Item.Kind.TODO:
|
||||
if item.assignee_id:
|
||||
item.assignee = None
|
||||
item.save(update_fields=['assignee', 'updated_at'])
|
||||
return item
|
||||
usernames = [match.group(1).lower() for match in MENTION_RE.finditer(without_markdown_code(item.content))]
|
||||
assignee = None
|
||||
if usernames:
|
||||
members = WorkspaceMembership.objects.filter(workspace=item.workspace, user__username__in=usernames).select_related('user')
|
||||
if item.team_id:
|
||||
members = members.filter(user__team_memberships__team=item.team)
|
||||
by_username = {membership.user.username.lower(): membership.user for membership in members}
|
||||
assignee = next((by_username[name] for name in usernames if name in by_username), None)
|
||||
if item.assignee_id != (assignee.id if assignee else None):
|
||||
item.assignee = assignee
|
||||
item.save(update_fields=['assignee', 'updated_at'])
|
||||
return item
|
||||
|
||||
|
||||
def apply_team_from_tags(item, user, force_private=False):
|
||||
if force_private:
|
||||
item.team = None
|
||||
@@ -517,6 +538,7 @@ def index(request):
|
||||
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)))
|
||||
assign_item_from_mentions(item)
|
||||
if request.headers.get('HX-Request'):
|
||||
active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()]
|
||||
response = render(request, 'core/_create_response.html', {'item': item, 'tags': index_filter_tags(request, request.user, prefs, active_tags, workspace=active_workspace(request)), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags})
|
||||
@@ -612,8 +634,10 @@ def new_item(request):
|
||||
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)))
|
||||
assign_item_from_mentions(item)
|
||||
if collection:
|
||||
item = adapt_item_for_collection(item, collection)
|
||||
assign_item_from_mentions(item)
|
||||
add_collection_section(collection, item)
|
||||
return redirect(next_url)
|
||||
return render(request, 'core/new_item.html', {
|
||||
@@ -635,6 +659,7 @@ def journal(request):
|
||||
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)))
|
||||
assign_item_from_mentions(item)
|
||||
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')
|
||||
@@ -1290,6 +1315,7 @@ def edit_item(request, pk):
|
||||
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)
|
||||
assign_item_from_mentions(item)
|
||||
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
|
||||
@@ -1320,6 +1346,7 @@ def item_editor(request, pk):
|
||||
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)
|
||||
assign_item_from_mentions(item)
|
||||
return redirect(next_url)
|
||||
return render(request, 'core/item_editor.html', {'item': item, 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'next_url': next_url})
|
||||
|
||||
@@ -1464,6 +1491,7 @@ def serialize_item(item):
|
||||
'comment': item.comment,
|
||||
'visibility': item.visibility,
|
||||
'team': item.team.slug if item.team else None,
|
||||
'assignee': item.assignee.username if item.assignee 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,
|
||||
@@ -1493,6 +1521,7 @@ def api_items(request):
|
||||
item = Item.objects.create(workspace=current_workspace(user, request.api_key), owner=user, kind=data.get('kind') or kind, visibility=data.get('visibility') or visibility, content=content, url=data.get('url') or url, due_at=due_at)
|
||||
item.sync_metadata()
|
||||
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I)))
|
||||
assign_item_from_mentions(item)
|
||||
return JsonResponse(serialize_item(item), status=201)
|
||||
|
||||
|
||||
@@ -1532,4 +1561,5 @@ def api_item_detail(request, pk):
|
||||
item.save()
|
||||
item.sync_metadata()
|
||||
apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||
assign_item_from_mentions(item)
|
||||
return JsonResponse(serialize_item(item))
|
||||
|
||||
Reference in New Issue
Block a user