Add workspace due indicators and todo assignment

This commit is contained in:
rucki
2026-09-11 12:19:38 +02:00
parent 291c8db471
commit d7472554df
7 changed files with 135 additions and 7 deletions
+13 -2
View File
@@ -1,7 +1,11 @@
from datetime import datetime, time
from django.db.models import Q
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _ 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): 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() membership = WorkspaceMembership.objects.select_related('workspace').filter(user=request.user, workspace_id=workspace_id).first()
if membership: if membership:
workspace = membership.workspace 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 { return {
'active_workspace': workspace, 'active_workspace': workspace,
'workspace_memberships': WorkspaceMembership.objects.filter(user=request.user).select_related('workspace').order_by('workspace__name'), 'workspace_memberships': memberships,
} }
+21
View File
@@ -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),
),
]
+1
View File
@@ -187,6 +187,7 @@ class Item(models.Model):
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='items') 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') 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') 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) kind = models.CharField(max_length=10, choices=Kind.choices, default=Kind.NOTE)
content = models.TextField() content = models.TextField()
+1
View File
@@ -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> <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 %} {% 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> <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 %} {% 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>
<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"> <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">
+1 -1
View File
@@ -32,7 +32,7 @@
{% if user.is_authenticated %} {% 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"> <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> <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><a class="dropdown-item" href="{% url 'settings' %}#workspaces">+ {% trans "Create workspace" %}</a></li>
<li><hr class="dropdown-divider"></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> <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>
+64
View File
@@ -104,6 +104,70 @@ class WorkspaceTenancyTests(TestCase):
self.client.force_login(outsider) self.client.force_login(outsider)
self.assertNotContains(self.client.get(reverse('index')), 'Workspace public') 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): def test_authenticated_overview_is_limited_to_current_workspace(self):
user = get_user_model().objects.create_user(username='tenant-visible', password='secret') user = get_user_model().objects.create_user(username='tenant-visible', password='secret')
other = get_user_model().objects.create_user(username='tenant-other', password='secret') other = get_user_model().objects.create_user(username='tenant-other', password='secret')
+34 -4
View File
@@ -7,7 +7,7 @@ from html import unescape
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from django.conf import settings from django.conf import settings
from django.contrib import messages 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.contrib.auth.decorators import login_required
from django.db import transaction from django.db import transaction
from django.db.models import Case, Count, IntegerField, ProtectedError, Q, Value, When 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) 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) 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) 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,})') CODE_FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})')
INLINE_CODE_RE = re.compile(r'(`+).*?\1', re.S) 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): def visible_items(user, api_key=None, workspace=None):
workspace = current_workspace(user, api_key, workspace) 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: if workspace:
qs = qs.filter(workspace=workspace) qs = qs.filter(workspace=workspace)
elif not user.is_authenticated: 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() qs = qs.filter(tags__in=allowed_tags).distinct()
return qs return qs
if user.is_authenticated: 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() return qs.none()
def user_can_edit_item(user, item): 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): def visible_tags(user, workspace=None):
@@ -282,6 +283,26 @@ def item_scope_fits_collections(item, visibility, team):
) for section in sections) ) 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): def apply_team_from_tags(item, user, force_private=False):
if force_private: if force_private:
item.team = None item.team = None
@@ -517,6 +538,7 @@ def index(request):
attach_files_and_replace_tokens(item, request.FILES.getlist('files')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I))) 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'): if request.headers.get('HX-Request'):
active_tags = [t.strip().lower() for t in request.GET.getlist('tag') if t.strip()] 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}) 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')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I))) 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: if collection:
item = adapt_item_for_collection(item, collection) item = adapt_item_for_collection(item, collection)
assign_item_from_mentions(item)
add_collection_section(collection, item) add_collection_section(collection, item)
return redirect(next_url) return redirect(next_url)
return render(request, 'core/new_item.html', { return render(request, 'core/new_item.html', {
@@ -635,6 +659,7 @@ def journal(request):
attach_files_and_replace_tokens(item, request.FILES.getlist('files')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I))) 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'): 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 render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
return redirect('journal') return redirect('journal')
@@ -1290,6 +1315,7 @@ def edit_item(request, pk):
attach_files_and_replace_tokens(item, request.FILES.getlist('files')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE) 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 = 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' response['HX-Trigger'] = 'tagsChanged'
return response return response
@@ -1320,6 +1346,7 @@ def item_editor(request, pk):
attach_files_and_replace_tokens(item, request.FILES.getlist('files')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE) apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
assign_item_from_mentions(item)
return redirect(next_url) 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}) 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, 'comment': item.comment,
'visibility': item.visibility, 'visibility': item.visibility,
'team': item.team.slug if item.team else None, 'team': item.team.slug if item.team else None,
'assignee': item.assignee.username if item.assignee else None,
'is_done': item.is_done, 'is_done': item.is_done,
'completed_at': item.completed_at.isoformat() if item.completed_at else None, 'completed_at': item.completed_at.isoformat() if item.completed_at else None,
'due_at': item.due_at.isoformat() if item.due_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 = 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() item.sync_metadata()
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I))) 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) return JsonResponse(serialize_item(item), status=201)
@@ -1532,4 +1561,5 @@ def api_item_detail(request, pk):
item.save() item.save()
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE) apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
assign_item_from_mentions(item)
return JsonResponse(serialize_item(item)) return JsonResponse(serialize_item(item))