Introduce workspace tenancy boundaries

This commit is contained in:
rucki
2026-09-09 17:46:10 +02:00
parent 7f203a7117
commit 291c8db471
11 changed files with 565 additions and 118 deletions
+162 -85
View File
@@ -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)