Introduce workspace tenancy boundaries
This commit is contained in:
+162
-85
@@ -20,8 +20,8 @@ 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, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, TAG_RE, without_markdown_code
|
||||
from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm, WorkspaceForm
|
||||
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference, Workspace, WorkspaceMembership, TAG_RE, get_or_create_personal_workspace, without_markdown_code
|
||||
from .templatetags.markdown_extras import strip_tags_outside_code
|
||||
|
||||
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
|
||||
@@ -188,14 +188,51 @@ def attach_files_and_replace_tokens(item, files):
|
||||
item.save(update_fields=['content', 'comment', 'updated_at'])
|
||||
|
||||
|
||||
def user_team_ids(user):
|
||||
def current_workspace(user, api_key=None, workspace=None):
|
||||
if api_key:
|
||||
return api_key.workspace
|
||||
if workspace:
|
||||
return workspace
|
||||
if user.is_authenticated:
|
||||
return get_or_create_personal_workspace(user)
|
||||
return None
|
||||
|
||||
|
||||
def user_workspaces(user):
|
||||
if not user.is_authenticated:
|
||||
return []
|
||||
return list(user.team_memberships.values_list('team_id', flat=True))
|
||||
return WorkspaceMembership.objects.filter(user=user).select_related('workspace').order_by('workspace__name')
|
||||
|
||||
|
||||
def visible_items(user, api_key=None):
|
||||
qs = Item.objects.select_related('owner', 'team').prefetch_related('tags', 'attachments', 'linked_items')
|
||||
def active_workspace(request):
|
||||
if not request.user.is_authenticated:
|
||||
return None
|
||||
workspace_id = request.session.get('workspace_id')
|
||||
if workspace_id:
|
||||
membership = WorkspaceMembership.objects.select_related('workspace').filter(user=request.user, workspace_id=workspace_id).first()
|
||||
if membership:
|
||||
return membership.workspace
|
||||
workspace = get_or_create_personal_workspace(request.user)
|
||||
request.session['workspace_id'] = workspace.id
|
||||
return workspace
|
||||
|
||||
|
||||
def user_team_ids(user, workspace=None):
|
||||
if not user.is_authenticated:
|
||||
return []
|
||||
qs = user.team_memberships
|
||||
if workspace:
|
||||
qs = qs.filter(team__workspace=workspace)
|
||||
return list(qs.values_list('team_id', flat=True))
|
||||
|
||||
|
||||
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')
|
||||
if workspace:
|
||||
qs = qs.filter(workspace=workspace)
|
||||
elif not user.is_authenticated:
|
||||
return qs.none()
|
||||
if api_key:
|
||||
qs = qs.filter(owner=api_key.owner)
|
||||
allowed_tags = api_key.tags.all()
|
||||
@@ -203,19 +240,20 @@ def visible_items(user, api_key=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)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
|
||||
return qs.filter(visibility=Item.Visibility.PUBLIC)
|
||||
return qs.filter(Q(owner=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())
|
||||
|
||||
|
||||
def visible_tags(user):
|
||||
def visible_tags(user, workspace=None):
|
||||
"""Tags from items the user may see; avoids leaking private tags from other users."""
|
||||
workspace = current_workspace(user, workspace=workspace)
|
||||
if not user.is_authenticated:
|
||||
return Tag.objects.filter(items__visibility=Item.Visibility.PUBLIC).distinct().order_by('name')
|
||||
return Tag.objects.filter(items__in=visible_items(user)).distinct().order_by('name')
|
||||
return Tag.objects.none()
|
||||
return Tag.objects.filter(workspace=workspace, items__in=visible_items(user, workspace=workspace)).distinct().order_by('name')
|
||||
|
||||
|
||||
def user_can_delete_item(user, item):
|
||||
@@ -228,7 +266,7 @@ def desired_item_scope(item, user, force_private=False):
|
||||
if item.visibility == Item.Visibility.PUBLIC:
|
||||
return Item.Visibility.PUBLIC, None
|
||||
tag_names = {match.group(1).lower() for match in re.finditer(r'(?<!\w)#([\wäöüÄÖÜß-]+)', without_markdown_code(item.content))}
|
||||
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
|
||||
membership = TeamMembership.objects.select_related('team').filter(user=user, team__workspace=item.workspace, team__slug__in=tag_names).first()
|
||||
if membership:
|
||||
return Item.Visibility.TEAM, membership.team
|
||||
visibility = Item.Visibility.PRIVATE if item.visibility == Item.Visibility.TEAM else item.visibility
|
||||
@@ -256,7 +294,7 @@ def apply_team_from_tags(item, user, force_private=False):
|
||||
item.save(update_fields=['team', 'updated_at'])
|
||||
return
|
||||
tag_names = list(item.tags.values_list('name', flat=True))
|
||||
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
|
||||
membership = TeamMembership.objects.select_related('team').filter(user=user, team__workspace=item.workspace, team__slug__in=tag_names).first()
|
||||
if membership:
|
||||
item.team = membership.team
|
||||
if item.visibility == Item.Visibility.PRIVATE:
|
||||
@@ -272,7 +310,7 @@ def get_api_key(request):
|
||||
token = request.headers.get('X-Api-Key') or request.headers.get('Authorization', '').removeprefix('Bearer ').strip()
|
||||
if not token:
|
||||
return None
|
||||
return ApiKey.objects.filter(token=token, is_active=True).select_related('owner').prefetch_related('tags').first()
|
||||
return ApiKey.objects.filter(token=token, is_active=True).select_related('owner', 'workspace').prefetch_related('tags').first()
|
||||
|
||||
|
||||
def api_auth_required(view):
|
||||
@@ -285,13 +323,13 @@ def api_auth_required(view):
|
||||
return wrapper
|
||||
|
||||
|
||||
def index_filter_tags(request, user, prefs, active_tags=None):
|
||||
def index_filter_tags(request, user, prefs, active_tags=None, workspace=None):
|
||||
active_tags = active_tags or []
|
||||
kind = request.GET.get('kind')
|
||||
status = request.GET.get('status')
|
||||
day = request.GET.get('day')
|
||||
q = request.GET.get('q', '').strip()
|
||||
items = visible_items(user)
|
||||
items = visible_items(user, workspace=workspace)
|
||||
applied_preferences = False
|
||||
if kind in dict(Item.Kind.choices):
|
||||
items = items.filter(kind=kind)
|
||||
@@ -322,7 +360,11 @@ def index_filter_tags(request, user, prefs, active_tags=None):
|
||||
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()
|
||||
return Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags)).distinct().order_by('name')
|
||||
tags = Tag.objects.filter(Q(items__in=items) | Q(name__in=active_tags))
|
||||
workspace = current_workspace(user, workspace=workspace)
|
||||
if workspace:
|
||||
tags = tags.filter(workspace=workspace)
|
||||
return tags.distinct().order_by('name')
|
||||
|
||||
|
||||
def index_filtered_items(request, prefs):
|
||||
@@ -333,7 +375,7 @@ def index_filtered_items(request, prefs):
|
||||
status = request.GET.get('status')
|
||||
day = request.GET.get('day')
|
||||
q = request.GET.get('q', '').strip()
|
||||
items = visible_items(request.user)
|
||||
items = visible_items(request.user, workspace=active_workspace(request))
|
||||
applied_preferences = False
|
||||
if active_tags:
|
||||
if active_tag_mode == 'all':
|
||||
@@ -443,7 +485,7 @@ def collection_sections_export(sections, level=2):
|
||||
|
||||
|
||||
def collection_export(request, pk):
|
||||
collection = get_object_or_404(visible_collections(request.user), pk=pk)
|
||||
collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)), pk=pk)
|
||||
lines = [f'# {collection.title}', '']
|
||||
if collection.description:
|
||||
lines.extend([collection.description.strip(), ''])
|
||||
@@ -471,13 +513,13 @@ def index(request):
|
||||
active_tag = request.GET.get('tag')
|
||||
if active_tag and not TAG_RE.search(without_markdown_code(content)):
|
||||
content = f'{content} #{active_tag.strip().lower()}'
|
||||
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
|
||||
item = Item.objects.create(workspace=active_workspace(request), owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
|
||||
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)))
|
||||
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), '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})
|
||||
response['HX-Trigger'] = 'tagsChanged'
|
||||
return response
|
||||
return redirect('index')
|
||||
@@ -493,7 +535,7 @@ def index(request):
|
||||
query['tag'] = q[1:].strip().lower()
|
||||
query.pop('q', None)
|
||||
return redirect(f'{request.path}?{query.urlencode()}')
|
||||
items = visible_items(request.user)
|
||||
items = visible_items(request.user, workspace=active_workspace(request))
|
||||
applied_preferences = False
|
||||
if active_tags:
|
||||
if active_tag_mode == 'all':
|
||||
@@ -540,7 +582,7 @@ def index(request):
|
||||
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': index_filter_tags(request, request.user, prefs, active_tags), 'composer_tags': visible_tags(request.user), 'item_kind_choices': Item.Kind.choices, 'selected_item_kind': prefs.default_item_kind, 'due_now_items': due_now_items, 'upcoming_due_items': upcoming_due_items, '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,
|
||||
'form': QuickItemForm(), 'items': items, 'tags': index_filter_tags(request, request.user, prefs, active_tags, workspace=active_workspace(request)), 'composer_tags': visible_tags(request.user, workspace=active_workspace(request)), 'item_kind_choices': Item.Kind.choices, 'selected_item_kind': prefs.default_item_kind, 'due_now_items': due_now_items, 'upcoming_due_items': upcoming_due_items, '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,
|
||||
})
|
||||
|
||||
|
||||
@@ -554,7 +596,7 @@ def new_item(request):
|
||||
collection = None
|
||||
collection_id = request.GET.get('collection') or request.POST.get('collection')
|
||||
if collection_id:
|
||||
collection = get_object_or_404(visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES), pk=collection_id)
|
||||
collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)).filter(mode__in=Collection.STRUCTURED_MODES), pk=collection_id)
|
||||
if not user_can_edit_collection(request.user, collection):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
requested_kind = Item.Kind.NOTE
|
||||
@@ -566,7 +608,7 @@ def new_item(request):
|
||||
raw = f'/{requested_kind} {raw}'
|
||||
raw, due_at, _ = parse_due_command(raw)
|
||||
kind, visibility, content, url = parse_quick_content(raw, requested_default_item_kind(request.POST, prefs, save=True))
|
||||
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
|
||||
item = Item.objects.create(workspace=active_workspace(request), owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
|
||||
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)))
|
||||
@@ -575,7 +617,7 @@ def new_item(request):
|
||||
add_collection_section(collection, item)
|
||||
return redirect(next_url)
|
||||
return render(request, 'core/new_item.html', {
|
||||
'form': QuickItemForm(), 'tags': visible_tags(request.user), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection,
|
||||
'form': QuickItemForm(), 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection,
|
||||
})
|
||||
|
||||
|
||||
@@ -589,7 +631,7 @@ def journal(request):
|
||||
raw = f'/journal {raw}'
|
||||
raw, due_at, _ = parse_due_command(raw)
|
||||
kind, visibility, content, url = parse_quick_content(raw)
|
||||
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
|
||||
item = Item.objects.create(workspace=active_workspace(request), owner=request.user, kind=kind, visibility=visibility, content=content, url=url, due_at=due_at)
|
||||
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)))
|
||||
@@ -602,7 +644,7 @@ def journal(request):
|
||||
selected = date.fromisoformat(selected_day)
|
||||
year = int(request.GET.get('year', selected.year))
|
||||
month = int(request.GET.get('month', selected.month))
|
||||
base = visible_items(request.user)
|
||||
base = visible_items(request.user, workspace=active_workspace(request))
|
||||
journal_entries = base.filter(kind=Item.Kind.JOURNAL, created_at__date=selected)
|
||||
created_entries = base.filter(kind__in=[Item.Kind.NOTE, Item.Kind.LINK], created_at__date=selected)
|
||||
completed_todos = base.filter(kind=Item.Kind.TODO, completed_at__date=selected)
|
||||
@@ -614,7 +656,7 @@ def journal(request):
|
||||
next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year)
|
||||
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||
return render(request, 'core/journal.html', {
|
||||
'form': QuickItemForm(), 'items': items, 'tags': visible_tags(request.user), 'weeks': weeks, 'year': year, 'month': month, 'selected_day': selected_day,
|
||||
'form': QuickItemForm(), 'items': items, 'tags': visible_tags(request.user, workspace=active_workspace(request)), '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,
|
||||
})
|
||||
|
||||
@@ -638,8 +680,20 @@ def set_user_language(request):
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def workspace_switch(request, pk):
|
||||
membership = get_object_or_404(WorkspaceMembership, workspace_id=pk, user=request.user)
|
||||
request.session['workspace_id'] = membership.workspace_id
|
||||
next_url = request.POST.get('next') or reverse('index')
|
||||
if not url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
|
||||
next_url = reverse('index')
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@login_required
|
||||
def settings_view(request):
|
||||
workspace = active_workspace(request)
|
||||
if request.method == 'POST':
|
||||
if request.POST.get('action') == 'profile':
|
||||
form = UserSettingsForm(request.POST, instance=request.user)
|
||||
@@ -654,10 +708,21 @@ def settings_view(request):
|
||||
pref_form.save()
|
||||
messages.success(request, _('Default view saved.'))
|
||||
return redirect('settings')
|
||||
elif request.POST.get('action') == 'workspace':
|
||||
workspace_form = WorkspaceForm(request.POST)
|
||||
if workspace_form.is_valid():
|
||||
workspace = workspace_form.save(commit=False)
|
||||
workspace.owner = request.user
|
||||
workspace.save()
|
||||
WorkspaceMembership.objects.create(workspace=workspace, user=request.user, role=WorkspaceMembership.Role.OWNER)
|
||||
request.session['workspace_id'] = workspace.id
|
||||
messages.success(request, _('Workspace created.'))
|
||||
return redirect('settings')
|
||||
elif request.POST.get('action') == 'apikey':
|
||||
key_form = ApiKeyForm(request.POST, user=request.user)
|
||||
key_form = ApiKeyForm(request.POST, user=request.user, workspace=workspace)
|
||||
if key_form.is_valid():
|
||||
api_key = key_form.save(commit=False)
|
||||
api_key.workspace = workspace
|
||||
api_key.owner = request.user
|
||||
api_key.save()
|
||||
key_form.save_m2m()
|
||||
@@ -675,7 +740,9 @@ def settings_view(request):
|
||||
return render(request, 'core/settings.html', {
|
||||
'profile_form': UserSettingsForm(instance=request.user),
|
||||
'preference_form': UserPreferenceForm(instance=prefs),
|
||||
'key_form': ApiKeyForm(user=request.user),
|
||||
'workspace_form': WorkspaceForm(),
|
||||
'workspace_memberships': user_workspaces(request.user),
|
||||
'key_form': ApiKeyForm(user=request.user, workspace=workspace),
|
||||
'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'),
|
||||
@@ -728,18 +795,19 @@ def accept_user_invite(request, token):
|
||||
@login_required
|
||||
def team_list(request):
|
||||
if request.method == 'POST':
|
||||
team_form = TeamForm(request.POST)
|
||||
team_form = TeamForm(request.POST, user=request.user, workspace=active_workspace(request))
|
||||
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)
|
||||
Tag.objects.get_or_create(workspace=team.workspace, 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')
|
||||
workspace = active_workspace(request)
|
||||
teams = Team.objects.filter(workspace=workspace, memberships__user=request.user).distinct().prefetch_related('memberships__user')
|
||||
stats = []
|
||||
base_items = visible_items(request.user)
|
||||
base_items = visible_items(request.user, workspace=active_workspace(request))
|
||||
for team in teams:
|
||||
team_items = base_items.filter(team=team)
|
||||
stats.append({
|
||||
@@ -749,12 +817,12 @@ def team_list(request):
|
||||
'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})
|
||||
return render(request, 'core/team_list.html', {'stats': stats, 'team_form': TeamForm(user=request.user, workspace=active_workspace(request)), '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)
|
||||
membership = get_object_or_404(TeamMembership.objects.select_related('team'), team_id=pk, user=request.user, team__workspace=active_workspace(request))
|
||||
team = membership.team
|
||||
if request.method == 'POST' and membership.role in [TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]:
|
||||
invite_form = TeamInviteForm(request.POST)
|
||||
@@ -765,7 +833,7 @@ def team_detail(request, pk):
|
||||
invite.save()
|
||||
messages.success(request, _('Invitation link created: %(url)s') % {'url': request.build_absolute_uri(reverse("team_accept_invite", args=[invite.token]))})
|
||||
return redirect('team_detail', pk=team.pk)
|
||||
items = visible_items(request.user).filter(team=team)
|
||||
items = visible_items(request.user, workspace=active_workspace(request)).filter(team=team)
|
||||
return render(request, 'core/team_detail.html', {
|
||||
'team': team,
|
||||
'membership': membership,
|
||||
@@ -780,18 +848,23 @@ def team_detail(request, pk):
|
||||
@login_required
|
||||
def team_accept_invite(request, token):
|
||||
invite = get_object_or_404(TeamInvite.objects.select_related('team'), token=token, accepted_at__isnull=True)
|
||||
WorkspaceMembership.objects.get_or_create(workspace=invite.team.workspace, user=request.user, defaults={'role': WorkspaceMembership.Role.MEMBER})
|
||||
TeamMembership.objects.get_or_create(team=invite.team, user=request.user, defaults={'role': TeamMembership.Role.MEMBER})
|
||||
request.session['workspace_id'] = invite.team.workspace_id
|
||||
invite.accepted_at = timezone.now()
|
||||
invite.save(update_fields=['accepted_at'])
|
||||
messages.success(request, _('You are now a member of team %(team)s.') % {'team': invite.team.name})
|
||||
return redirect('team_detail', pk=invite.team.pk)
|
||||
|
||||
|
||||
def visible_collections(user):
|
||||
qs = Collection.objects.select_related('owner', 'team').prefetch_related('filter_tags', 'sections__item', 'sections__sub_collection')
|
||||
def visible_collections(user, workspace=None):
|
||||
workspace = current_workspace(user, workspace=workspace)
|
||||
qs = Collection.objects.select_related('owner', 'team', 'workspace').prefetch_related('filter_tags', 'sections__item', 'sections__sub_collection')
|
||||
if workspace:
|
||||
qs = qs.filter(workspace=workspace)
|
||||
if user.is_authenticated:
|
||||
return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
|
||||
return qs.filter(visibility=Item.Visibility.PUBLIC)
|
||||
return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user, workspace)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
|
||||
return qs.none()
|
||||
|
||||
|
||||
def user_can_edit_collection(user, collection):
|
||||
@@ -844,7 +917,7 @@ def collection_target_from_form(form, user):
|
||||
|
||||
|
||||
def collection_tag_items(collection):
|
||||
items = Item.objects.filter(kind__in=collection.included_item_kinds()).select_related('owner', 'team').prefetch_related('tags', 'attachments')
|
||||
items = Item.objects.filter(workspace=collection.workspace, kind__in=collection.included_item_kinds()).select_related('owner', 'team').prefetch_related('tags', 'attachments')
|
||||
if collection.visibility == Item.Visibility.PUBLIC:
|
||||
items = items.filter(visibility=Item.Visibility.PUBLIC)
|
||||
elif collection.visibility == Item.Visibility.TEAM:
|
||||
@@ -870,20 +943,21 @@ def item_can_change_for_collection(user, item, collection):
|
||||
return True
|
||||
|
||||
|
||||
def remove_team_tags(content):
|
||||
slugs = Team.objects.values_list('slug', flat=True)
|
||||
def remove_team_tags(content, workspace):
|
||||
slugs = Team.objects.filter(workspace=workspace).values_list('slug', flat=True)
|
||||
for slug in slugs:
|
||||
content = re.sub(rf'(?<!\w)#{re.escape(slug)}\b', '', content, flags=re.I)
|
||||
return re.sub(r'[ \t]{2,}', ' ', content).strip()
|
||||
|
||||
|
||||
def adapt_item_for_collection(item, collection):
|
||||
item.content = remove_team_tags(item.content)
|
||||
item.workspace = collection.workspace
|
||||
item.content = remove_team_tags(item.content, collection.workspace)
|
||||
if collection.team:
|
||||
item.content = f'{item.content} #{collection.team.slug}'.strip()
|
||||
item.visibility = collection.visibility
|
||||
item.team = collection.team
|
||||
item.save(update_fields=['content', 'visibility', 'team', 'updated_at'])
|
||||
item.save(update_fields=['workspace', 'content', 'visibility', 'team', 'updated_at'])
|
||||
item.sync_metadata()
|
||||
item.team = collection.team
|
||||
item.visibility = collection.visibility
|
||||
@@ -893,7 +967,7 @@ def adapt_item_for_collection(item, collection):
|
||||
|
||||
def copy_item_for_collection(item, collection, owner):
|
||||
copied = Item.objects.create(
|
||||
owner=owner, team=collection.team, kind=Item.Kind.NOTE, content=item.content,
|
||||
workspace=collection.workspace, owner=owner, team=collection.team, kind=Item.Kind.NOTE, content=item.content,
|
||||
comment=item.comment, visibility=collection.visibility, url=item.url,
|
||||
)
|
||||
for attachment in item.attachments.all():
|
||||
@@ -920,10 +994,11 @@ def add_collection_subcollection(collection, sub_collection):
|
||||
)
|
||||
|
||||
|
||||
def editable_collection_notes(user):
|
||||
def editable_collection_notes(user, workspace=None):
|
||||
workspace = current_workspace(user, workspace=workspace)
|
||||
return Item.objects.select_related('owner', 'team').prefetch_related('tags', 'attachments').filter(
|
||||
kind=Item.Kind.NOTE,
|
||||
).filter(Q(owner=user) | Q(team_id__in=user_team_ids(user))).distinct()
|
||||
workspace=workspace, kind=Item.Kind.NOTE,
|
||||
).filter(Q(owner=user) | Q(team_id__in=user_team_ids(user, workspace))).distinct()
|
||||
|
||||
|
||||
def collection_is_structured_mode(mode):
|
||||
@@ -938,8 +1013,8 @@ def collection_detail_context(request, collection, pending_item=None):
|
||||
if can_edit:
|
||||
used_item_ids = set(collection.sections.filter(item__isnull=False).values_list('item_id', flat=True))
|
||||
used_collection_ids = set(collection.sections.filter(sub_collection__isnull=False).values_list('sub_collection_id', flat=True))
|
||||
candidates = editable_collection_notes(request.user).annotate(collection_use_count=Count('collection_sections', distinct=True))
|
||||
collection_candidates = visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES).exclude(pk=collection.pk).annotate(collection_use_count=Count('containing_sections', distinct=True))
|
||||
candidates = editable_collection_notes(request.user, workspace=collection.workspace).annotate(collection_use_count=Count('collection_sections', distinct=True))
|
||||
collection_candidates = visible_collections(request.user, workspace=active_workspace(request)).filter(mode__in=Collection.STRUCTURED_MODES).exclude(pk=collection.pk).annotate(collection_use_count=Count('containing_sections', distinct=True))
|
||||
collection_candidates = [candidate for candidate in collection_candidates if collection_subcollection_is_compatible(collection, candidate) and not collection_contains_collection(candidate, collection)]
|
||||
if q:
|
||||
candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
|
||||
@@ -966,7 +1041,7 @@ def collection_detail_context(request, collection, pending_item=None):
|
||||
@login_required
|
||||
def collection_list(request):
|
||||
active_mode = request.GET.get('mode', 'listed')
|
||||
collections = visible_collections(request.user).prefetch_related('filter_tags')
|
||||
collections = visible_collections(request.user, workspace=active_workspace(request)).prefetch_related('filter_tags')
|
||||
if active_mode == 'manual':
|
||||
collections = collections.filter(mode=Collection.Mode.MANUAL)
|
||||
elif active_mode == 'tutorial':
|
||||
@@ -982,20 +1057,21 @@ def collection_list(request):
|
||||
for collection in collections:
|
||||
count = collection_tag_items(collection).count() if collection.mode == Collection.Mode.TAGS else collection.sections.count()
|
||||
entries.append({'collection': collection, 'section_count': count})
|
||||
orphan_chapters = visible_collections(request.user).filter(mode=Collection.Mode.CHAPTER, containing_sections__isnull=True) if active_mode != 'chapter' else Collection.objects.none()
|
||||
orphan_chapters = visible_collections(request.user, workspace=active_workspace(request)).filter(mode=Collection.Mode.CHAPTER, containing_sections__isnull=True) if active_mode != 'chapter' else Collection.objects.none()
|
||||
return render(request, 'core/collection_list.html', {'entries': entries, 'active_mode': active_mode, 'orphan_chapters': orphan_chapters})
|
||||
|
||||
|
||||
@login_required
|
||||
def collection_create(request):
|
||||
if request.method == 'POST':
|
||||
form = CollectionForm(request.POST, user=request.user)
|
||||
form = CollectionForm(request.POST, user=request.user, workspace=active_workspace(request))
|
||||
if form.is_valid():
|
||||
visibility, team = collection_target_from_form(form, request.user)
|
||||
if visibility == Item.Visibility.TEAM and not team:
|
||||
form.add_error('team', _('Select a team for a team collection.'))
|
||||
else:
|
||||
collection = form.save(commit=False)
|
||||
collection.workspace = active_workspace(request)
|
||||
collection.owner = request.user
|
||||
collection.visibility = visibility
|
||||
collection.team = team
|
||||
@@ -1006,13 +1082,13 @@ def collection_create(request):
|
||||
messages.success(request, _('Collection created.'))
|
||||
return redirect(collection)
|
||||
else:
|
||||
form = CollectionForm(user=request.user)
|
||||
form = CollectionForm(user=request.user, workspace=active_workspace(request))
|
||||
return render(request, 'core/collection_form.html', {'form': form, 'heading': _('Create collection')})
|
||||
|
||||
|
||||
@login_required
|
||||
def collection_edit(request, pk):
|
||||
collection = get_object_or_404(visible_collections(request.user), pk=pk)
|
||||
collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)), pk=pk)
|
||||
if not user_can_edit_collection(request.user, collection):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
if request.method == 'POST':
|
||||
@@ -1053,7 +1129,7 @@ def collection_edit(request, pk):
|
||||
|
||||
|
||||
def collection_detail(request, pk):
|
||||
collection = get_object_or_404(visible_collections(request.user), pk=pk)
|
||||
collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)), pk=pk)
|
||||
tag_items = list(collection_tag_items(collection)) if collection.mode == Collection.Mode.TAGS else []
|
||||
for item in tag_items:
|
||||
item.can_toggle_done = item.kind == Item.Kind.TODO and user_can_edit_item(request.user, item)
|
||||
@@ -1067,7 +1143,7 @@ def collection_detail(request, pk):
|
||||
|
||||
@login_required
|
||||
def collection_manage(request, pk):
|
||||
collection = get_object_or_404(visible_collections(request.user), pk=pk)
|
||||
collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)), pk=pk)
|
||||
if not user_can_edit_collection(request.user, collection):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
if collection.mode == Collection.Mode.TAGS:
|
||||
@@ -1078,7 +1154,7 @@ def collection_manage(request, pk):
|
||||
if request.method == 'POST':
|
||||
action = request.POST.get('action')
|
||||
if action in {'add', 'confirm_add'}:
|
||||
item = get_object_or_404(editable_collection_notes(request.user), pk=request.POST.get('item_id'))
|
||||
item = get_object_or_404(editable_collection_notes(request.user, workspace=collection.workspace), pk=request.POST.get('item_id'))
|
||||
if collection_item_is_compatible(collection, item):
|
||||
add_collection_section(collection, item)
|
||||
return redirect(redirect_url)
|
||||
@@ -1100,7 +1176,7 @@ def collection_manage(request, pk):
|
||||
title = request.POST.get('title', '').strip()[:200]
|
||||
if title:
|
||||
chapter = Collection.objects.create(
|
||||
owner=request.user, title=title, mode=Collection.Mode.CHAPTER,
|
||||
workspace=collection.workspace, owner=request.user, title=title, mode=Collection.Mode.CHAPTER,
|
||||
visibility=collection.visibility, team=collection.team,
|
||||
)
|
||||
add_collection_subcollection(collection, chapter)
|
||||
@@ -1108,7 +1184,7 @@ def collection_manage(request, pk):
|
||||
messages.error(request, _('Enter a chapter title.'))
|
||||
return redirect(redirect_url)
|
||||
if action == 'add_collection':
|
||||
sub_collection = get_object_or_404(visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES), pk=request.POST.get('collection_id'))
|
||||
sub_collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)).filter(mode__in=Collection.STRUCTURED_MODES), pk=request.POST.get('collection_id'))
|
||||
if sub_collection.pk == collection.pk or collection_contains_collection(sub_collection, collection):
|
||||
messages.error(request, _('This would create a collection loop.'))
|
||||
elif not collection_subcollection_is_compatible(collection, sub_collection):
|
||||
@@ -1154,7 +1230,7 @@ def collection_manage(request, pk):
|
||||
@login_required
|
||||
@require_POST
|
||||
def collection_delete(request, pk):
|
||||
collection = get_object_or_404(visible_collections(request.user), pk=pk)
|
||||
collection = get_object_or_404(visible_collections(request.user, workspace=active_workspace(request)), pk=pk)
|
||||
if not user_can_delete_collection(request.user, collection):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
collection.delete()
|
||||
@@ -1164,14 +1240,14 @@ def collection_delete(request, pk):
|
||||
|
||||
@login_required
|
||||
def item_detail(request, pk):
|
||||
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||
item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk)
|
||||
return render(request, 'core/detail.html', {'item': item})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def toggle_done(request, pk):
|
||||
item = get_object_or_404(visible_items(request.user), pk=pk, kind=Item.Kind.TODO)
|
||||
item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk, kind=Item.Kind.TODO)
|
||||
if not user_can_edit_item(request.user, item):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
item.is_done = not item.is_done
|
||||
@@ -1187,13 +1263,13 @@ def toggle_done(request, pk):
|
||||
|
||||
@login_required
|
||||
def item_card(request, pk):
|
||||
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||
item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk)
|
||||
return render(request, 'core/_item.html', {'item': item})
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_item(request, pk):
|
||||
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||
item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk)
|
||||
if not user_can_edit_item(request.user, item):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
if request.method == 'POST':
|
||||
@@ -1206,7 +1282,7 @@ def edit_item(request, pk):
|
||||
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||
if not item_scope_fits_collections(item, target_visibility, target_team):
|
||||
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
|
||||
return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': visible_tags(request.user)}, status=409)
|
||||
return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': visible_tags(request.user, workspace=active_workspace(request))}, status=409)
|
||||
item.visibility, item.team = target_visibility, target_team
|
||||
item.save()
|
||||
form.save_m2m()
|
||||
@@ -1217,12 +1293,12 @@ def edit_item(request, pk):
|
||||
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
|
||||
return render(request, 'core/_edit_form.html', {'item': item, 'tags': visible_tags(request.user)})
|
||||
return render(request, 'core/_edit_form.html', {'item': item, 'tags': visible_tags(request.user, workspace=active_workspace(request))})
|
||||
|
||||
|
||||
@login_required
|
||||
def item_editor(request, pk):
|
||||
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||
item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk)
|
||||
if not user_can_edit_item(request.user, item):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
next_url = request.GET.get('next') or item.get_absolute_url()
|
||||
@@ -1236,7 +1312,7 @@ def item_editor(request, pk):
|
||||
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||
if not item_scope_fits_collections(item, target_visibility, target_team):
|
||||
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
|
||||
return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': visible_tags(request.user), 'next_url': next_url}, status=409)
|
||||
return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': visible_tags(request.user, workspace=active_workspace(request)), 'next_url': next_url}, status=409)
|
||||
item.visibility, item.team = target_visibility, target_team
|
||||
item.save()
|
||||
form.save_m2m()
|
||||
@@ -1245,13 +1321,13 @@ def item_editor(request, pk):
|
||||
item.sync_metadata()
|
||||
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||
return redirect(next_url)
|
||||
return render(request, 'core/item_editor.html', {'item': item, 'tags': visible_tags(request.user), '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})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_item(request, pk):
|
||||
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||
item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=pk)
|
||||
if not user_can_delete_item(request.user, item):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
try:
|
||||
@@ -1265,24 +1341,25 @@ def delete_item(request, pk):
|
||||
@login_required
|
||||
def kanban_list(request):
|
||||
if request.method == 'POST':
|
||||
form = KanbanForm(request.POST)
|
||||
form = KanbanForm(request.POST, user=request.user, workspace=active_workspace(request))
|
||||
if form.is_valid():
|
||||
kanban = form.save(commit=False)
|
||||
kanban.owner = request.user
|
||||
kanban.save()
|
||||
return redirect('kanban_detail', pk=kanban.pk)
|
||||
kanbans = Kanban.objects.filter(owner=request.user).select_related('tag')
|
||||
workspace = active_workspace(request)
|
||||
kanbans = Kanban.objects.filter(workspace=workspace, owner=request.user).select_related('tag')
|
||||
stats = []
|
||||
for kanban in kanbans:
|
||||
qs = Item.objects.filter(owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag)
|
||||
qs = Item.objects.filter(workspace=workspace, owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag)
|
||||
stats.append({'kanban': kanban, 'open': qs.filter(is_done=False).count(), 'done': qs.filter(is_done=True).count()})
|
||||
return render(request, 'core/kanban_list.html', {'form': KanbanForm(), 'stats': stats})
|
||||
return render(request, 'core/kanban_list.html', {'form': KanbanForm(user=request.user, workspace=active_workspace(request)), 'stats': stats})
|
||||
|
||||
|
||||
@login_required
|
||||
def kanban_detail(request, pk):
|
||||
kanban = get_object_or_404(Kanban.objects.select_related('tag'), pk=pk, owner=request.user)
|
||||
todos = Item.objects.filter(owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag).prefetch_related('tags')
|
||||
todos = Item.objects.filter(workspace=kanban.workspace, owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag).prefetch_related('tags')
|
||||
return render(request, 'core/kanban_detail.html', {
|
||||
'kanban': kanban,
|
||||
'inbox_items': todos.filter(is_done=False, kanban_status='inbox'),
|
||||
@@ -1296,7 +1373,7 @@ def kanban_detail(request, pk):
|
||||
@require_POST
|
||||
def kanban_move(request, pk, item_pk):
|
||||
kanban = get_object_or_404(Kanban, pk=pk, owner=request.user)
|
||||
item = get_object_or_404(Item, pk=item_pk, owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag)
|
||||
item = get_object_or_404(Item, workspace=kanban.workspace, pk=item_pk, owner=request.user, kind=Item.Kind.TODO, tags=kanban.tag)
|
||||
status = request.POST.get('status')
|
||||
if status not in {'inbox', 'todo', 'progress', 'done'}:
|
||||
return JsonResponse({'error': 'invalid status'}, status=400)
|
||||
@@ -1312,7 +1389,7 @@ def tag_list(request):
|
||||
prefs, created = UserPreference.objects.get_or_create(user=request.user)
|
||||
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': index_filter_tags(request, request.user, prefs, active_tags), 'active_tag': active_tags[0] if active_tags else None, 'active_tags': active_tags, 'active_tag_mode': active_tag_mode})
|
||||
return render(request, 'core/_tags.html', {'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, 'active_tag_mode': active_tag_mode})
|
||||
|
||||
|
||||
def requested_default_item_kind(post, prefs, save=False):
|
||||
@@ -1334,12 +1411,12 @@ def set_default_item_kind(request):
|
||||
|
||||
@login_required
|
||||
def api_tags(request):
|
||||
return JsonResponse({'tags': list(visible_tags(request.user).values_list('name', flat=True))})
|
||||
return JsonResponse({'tags': list(visible_tags(request.user, workspace=active_workspace(request)).values_list('name', flat=True))})
|
||||
|
||||
|
||||
@login_required
|
||||
def api_attachment_suggestions(request):
|
||||
item = get_object_or_404(visible_items(request.user), pk=request.GET.get('item'))
|
||||
item = get_object_or_404(visible_items(request.user, workspace=active_workspace(request)), pk=request.GET.get('item'))
|
||||
if not user_can_edit_item(request.user, item):
|
||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||
data = [{'id': a.id, 'name': a.file.name.split('/')[-1], 'url': a.file.url} for a in item.attachments.all()]
|
||||
@@ -1349,7 +1426,7 @@ def api_attachment_suggestions(request):
|
||||
@login_required
|
||||
def api_item_suggestions(request):
|
||||
q = request.GET.get('q', '').strip()
|
||||
items = visible_items(request.user)
|
||||
items = visible_items(request.user, workspace=active_workspace(request))
|
||||
if q:
|
||||
items = items.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
|
||||
data = [{'id': i.id, 'kind': i.kind, 'label': i.content[:80]} for i in items[:10]]
|
||||
@@ -1413,7 +1490,7 @@ def api_items(request):
|
||||
prefs, created = UserPreference.objects.get_or_create(user=user)
|
||||
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
|
||||
due_at = data['due_at'] if 'due_at' in data else command_due_at
|
||||
item = Item.objects.create(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()
|
||||
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I)))
|
||||
return JsonResponse(serialize_item(item), status=201)
|
||||
|
||||
Reference in New Issue
Block a user