import json
import re
from functools import wraps
from calendar import Calendar
from datetime import date, datetime, time, timedelta
from html import unescape
from urllib.request import Request, urlopen
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.db.models import Case, Count, IntegerField, ProtectedError, Q, Value, When
from django.http import HttpResponse, JsonResponse
from django.urls import reverse
from django.utils import timezone
from django.utils import translation
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, without_markdown_code
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I)
URL_RE = re.compile(r'https?://\S+')
ONLY_URL_RE = re.compile(r'^https?://\S+$')
FIRST_LINE_URL_RE = re.compile(r'^(https?://\S+)[ \t]*(?:\r?\n|$)')
TITLE_RE = re.compile(r'
]*>(.*?)', re.I | re.S)
DATE_COMMAND_RE = re.compile(r'/datum\s+(\d{1,2}\.\d{1,2}\.(?:\d{2}|\d{4}))(?:\s+(\d{1,2}:\d{2}))?\b', re.I)
RELATIVE_DATE_COMMAND_RE = re.compile(r'/(morgen|übermorgen|uebermorgen|nächstewoche|naechstewoche)\b', re.I)
CODE_FENCE_RE = re.compile(r'^\s*(`{3,}|~{3,})')
INLINE_CODE_RE = re.compile(r'(`+).*?\1', re.S)
def mask_markdown_code(text):
"""Replace Markdown code with spaces while preserving string positions."""
masked_lines = []
fence = None
for line in (text or '').splitlines(keepends=True):
line_without_newline = line.rstrip('\r\n')
newline = line[len(line_without_newline):]
match = CODE_FENCE_RE.match(line_without_newline)
if fence:
masked_lines.append(' ' * len(line_without_newline) + newline)
if match and match.group(1)[0] == fence[0] and len(match.group(1)) >= len(fence):
fence = None
elif match:
fence = match.group(1)
masked_lines.append(' ' * len(line_without_newline) + newline)
else:
masked_lines.append(INLINE_CODE_RE.sub(lambda m: ' ' * (m.end() - m.start()), line_without_newline) + newline)
return ''.join(masked_lines)
def remove_spans(text, spans):
for start, end in sorted(spans, reverse=True):
text = f'{text[:start]} {text[end:]}'
return re.sub(r'[ \t]{2,}', ' ', text).strip()
def fetch_page_title(url):
try:
request = Request(url, headers={'User-Agent': 'my2dos/1.0'})
with urlopen(request, timeout=4) as response:
html = response.read(200_000).decode(response.headers.get_content_charset() or 'utf-8', errors='ignore')
match = TITLE_RE.search(html)
if match:
return re.sub(r'\s+', ' ', unescape(match.group(1))).strip()
except Exception:
pass
return ''
def parse_quick_content(raw, default_kind=Item.Kind.NOTE):
content = raw.strip()
type_match = TYPE_COMMAND_RE.match(content)
type_command = type_match.group(1).lower() if type_match else None
masked_content = mask_markdown_code(content)
visibility_matches = list(VISIBILITY_COMMAND_RE.finditer(masked_content))
visibility_commands = {match.group(1).lower() for match in visibility_matches}
offset = 0
if type_match:
remainder = content[type_match.end():]
leading_space = len(remainder) - len(remainder.lstrip())
offset = type_match.end() + leading_space
content = remainder.strip()
visibility_spans = [(match.start() - offset, match.end() - offset) for match in visibility_matches if match.start() >= offset]
content = remove_spans(content, visibility_spans)
only_url = bool(ONLY_URL_RE.match(content))
first_line_url_match = FIRST_LINE_URL_RE.match(content)
if type_command == 'todo':
kind = Item.Kind.TODO
elif type_command == 'journal':
kind = Item.Kind.JOURNAL
elif type_command == 'link' or only_url or first_line_url_match:
kind = Item.Kind.LINK
elif type_command == 'note':
kind = Item.Kind.NOTE
else:
kind = default_kind if default_kind in Item.Kind.values else Item.Kind.NOTE
visibility = Item.Visibility.PUBLIC if 'public' in visibility_commands else Item.Visibility.PRIVATE
url_match = first_line_url_match or URL_RE.search(content)
url = url_match.group(1) if kind == Item.Kind.LINK and first_line_url_match else (url_match.group(0) if kind == Item.Kind.LINK and url_match else '')
if first_line_url_match:
title = fetch_page_title(url) or url
extra_content = content[first_line_url_match.end():].strip()
content = f'{title}\n{extra_content}' if extra_content else title
elif only_url:
content = fetch_page_title(url) or url
return kind, visibility, content, url
def parse_due_command(raw):
"""Extract a due date slash command and return cleaned text, date and match state."""
masked_raw = mask_markdown_code(raw)
date_match = DATE_COMMAND_RE.search(masked_raw)
relative_match = RELATIVE_DATE_COMMAND_RE.search(masked_raw)
match = date_match or relative_match
if not match:
return raw, None, False
due_time = time(23, 59)
if date_match:
date_format = '%d.%m.%Y' if len(date_match.group(1).rsplit('.', 1)[-1]) == 4 else '%d.%m.%y'
try:
due_date = datetime.strptime(date_match.group(1), date_format).date()
if date_match.group(2):
due_time = time.fromisoformat(date_match.group(2))
except ValueError:
return raw, None, False
else:
today = timezone.localdate()
command = relative_match.group(1).lower()
if command == 'morgen':
due_date = today + timedelta(days=1)
elif command in {'übermorgen', 'uebermorgen'}:
due_date = today + timedelta(days=2)
else:
due_date = today + timedelta(days=(7 - today.weekday()))
cleaned = remove_spans(raw, [(match.start(), match.end())])
return cleaned, timezone.make_aware(datetime.combine(due_date, due_time)), True
def parse_due_at_from_post(post):
due_date = (post.get('due_date') or '').strip()
due_time = (post.get('due_time') or '').strip()
if not due_date:
return None
try:
parsed_date = date.fromisoformat(due_date)
parsed_time = time.fromisoformat(due_time) if due_time else time(23, 59)
return timezone.make_aware(datetime.combine(parsed_date, parsed_time))
except ValueError:
return None
def attach_files_and_replace_tokens(item, files):
replacements = {}
attached = False
for file in files:
attachment = Attachment.objects.create(item=item, file=file)
attached = True
replacements[f'![[paste:{file.name}]]'] = f'![[file:{attachment.id}]]'
if attached and hasattr(item, '_prefetched_objects_cache'):
item._prefetched_objects_cache.pop('attachments', None)
if replacements:
content = item.content
comment = item.comment
for old, new in replacements.items():
content = content.replace(old, new)
comment = comment.replace(old, new)
if content != item.content or comment != item.comment:
item.content = content
item.comment = comment
item.save(update_fields=['content', 'comment', 'updated_at'])
def user_team_ids(user):
if not user.is_authenticated:
return []
return list(user.team_memberships.values_list('team_id', flat=True))
def visible_items(user, api_key=None):
qs = Item.objects.select_related('owner', 'team').prefetch_related('tags', 'attachments', 'linked_items')
if api_key:
qs = qs.filter(owner=api_key.owner)
allowed_tags = api_key.tags.all()
if allowed_tags.exists():
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)
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 user_can_delete_item(user, item):
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists())
def desired_item_scope(item, user, force_private=False):
if force_private:
return Item.Visibility.PRIVATE, None
if item.visibility == Item.Visibility.PUBLIC:
return Item.Visibility.PUBLIC, None
tag_names = {match.group(1).lower() for match in re.finditer(r'(? 1:
query = request.GET.copy()
query['tag'] = q[1:].strip().lower()
query.pop('q', None)
return redirect(f'{request.path}?{query.urlencode()}')
items = visible_items(request.user)
applied_preferences = False
if active_tags:
if active_tag_mode == 'all':
for tag_name in active_tags:
items = items.filter(tags__name=tag_name)
else:
items = items.filter(tags__name__in=active_tags).distinct()
if kind in dict(Item.Kind.choices):
items = items.filter(kind=kind)
elif not any([tag, status, q, day]):
applied_preferences = True
type_filter = Q()
if prefs.show_notes:
type_filter |= Q(kind=Item.Kind.NOTE)
if prefs.show_links:
type_filter |= Q(kind=Item.Kind.LINK)
if prefs.show_journal:
type_filter |= Q(kind=Item.Kind.JOURNAL)
if prefs.show_open_todos:
type_filter |= Q(kind=Item.Kind.TODO, is_done=False)
if prefs.show_done_todos:
type_filter |= Q(kind=Item.Kind.TODO, is_done=True)
items = items.filter(type_filter) if type_filter else items.none()
if day:
items = items.filter(created_at__date=day)
if status == 'open':
items = items.filter(kind=Item.Kind.TODO, is_done=False)
elif status == 'due':
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59)))
items = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).order_by('due_at', '-created_at')
elif status == 'archive':
items = items.filter(kind=Item.Kind.TODO, is_done=True)
elif not applied_preferences:
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()
today_end = timezone.make_aware(datetime.combine(timezone.localdate(), time(23, 59, 59)))
due_count = items.filter(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end).count()
if prefs.due_first and status != 'archive':
items = items.annotate(due_rank=Case(
When(kind=Item.Kind.TODO, is_done=False, due_at__lte=today_end, then=Value(0)),
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': Tag.objects.all(), '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,
})
@login_required
def new_item(request):
prefs, created = UserPreference.objects.get_or_create(user=request.user)
requested_kind = request.GET.get('kind') or prefs.default_item_kind
next_url = request.GET.get('next') or request.POST.get('next') or reverse('index')
if next_url == 'index':
next_url = reverse('index')
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)
if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403)
requested_kind = Item.Kind.NOTE
if request.method == 'POST':
form = QuickItemForm(request.POST, request.FILES)
if form.is_valid():
raw = form.cleaned_data['content']
if requested_kind in {'todo', 'link', 'journal'} and not TYPE_COMMAND_RE.match(raw.strip()):
raw = f'/{requested_kind} {raw}'
raw, due_at, _ = parse_due_command(raw)
kind, visibility, content, url = parse_quick_content(raw, prefs.default_item_kind)
item = Item.objects.create(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 collection:
item = adapt_item_for_collection(item, collection)
add_collection_section(collection, item)
return redirect(next_url)
return render(request, 'core/new_item.html', {
'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection,
})
@login_required
def journal(request):
if request.method == 'POST':
form = QuickItemForm(request.POST, request.FILES)
if form.is_valid():
raw = form.cleaned_data['content']
if not TYPE_COMMAND_RE.match(raw.strip()):
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)
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'):
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')
today = date.today()
selected_day = request.GET.get('day') or today.isoformat()
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)
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)
items = (journal_entries | created_entries | completed_todos).distinct().order_by('-created_at')
activity_dates = set(base.filter(kind__in=[Item.Kind.JOURNAL, Item.Kind.NOTE, Item.Kind.LINK], created_at__year=year, created_at__month=month).dates('created_at', 'day'))
activity_dates |= set(base.filter(kind=Item.Kind.TODO, completed_at__year=year, completed_at__month=month).dates('completed_at', 'day'))
weeks = [[{'day': d, 'date': date(year, month, d).isoformat() if d else '', 'has_entries': d and date(year, month, d) in activity_dates} for d in week] for week in Calendar(firstweekday=0).monthdayscalendar(year, month)]
prev_month, prev_year = (12, year - 1) if month == 1 else (month - 1, year)
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': Tag.objects.all(), '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,
})
@require_POST
def set_user_language(request):
language = request.POST.get('language')
next_url = request.POST.get('next') or '/'
if language not in {'de', 'en', ''}:
language = ''
if request.user.is_authenticated:
prefs, created = UserPreference.objects.get_or_create(user=request.user)
prefs.language = language
prefs.save(update_fields=['language'])
response = redirect(next_url)
if language:
translation.activate(language)
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
else:
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
return response
@login_required
def settings_view(request):
if request.method == 'POST':
if request.POST.get('action') == 'profile':
form = UserSettingsForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
messages.success(request, _('Settings saved.'))
return redirect('settings')
elif request.POST.get('action') == 'preferences':
prefs, created = UserPreference.objects.get_or_create(user=request.user)
pref_form = UserPreferenceForm(request.POST, instance=prefs)
if pref_form.is_valid():
pref_form.save()
messages.success(request, _('Default view saved.'))
return redirect('settings')
elif request.POST.get('action') == 'apikey':
key_form = ApiKeyForm(request.POST)
if key_form.is_valid():
api_key = key_form.save(commit=False)
api_key.owner = request.user
api_key.save()
key_form.save_m2m()
messages.success(request, _('API key created: %(token)s') % {'token': api_key.token})
return redirect('settings')
elif request.POST.get('action') == 'userinvite':
invite_form = UserInviteForm(request.POST)
if invite_form.is_valid():
invite = invite_form.save(commit=False)
invite.invited_by = request.user
invite.save()
messages.success(request, _('Invitation link created: %(url)s') % {'url': request.build_absolute_uri(reverse("accept_user_invite", args=[invite.token]))})
return redirect('settings')
prefs, created = UserPreference.objects.get_or_create(user=request.user)
return render(request, 'core/settings.html', {
'profile_form': UserSettingsForm(instance=request.user),
'preference_form': UserPreferenceForm(instance=prefs),
'key_form': ApiKeyForm(),
'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'),
})
@login_required
@require_POST
def delete_api_key(request, pk):
api_key = get_object_or_404(ApiKey, pk=pk, owner=request.user)
api_key.delete()
return redirect('settings')
@login_required
@require_POST
def delete_user_invite(request, pk):
invite = get_object_or_404(UserInvite, pk=pk, invited_by=request.user, accepted_at__isnull=True)
invite.delete()
if request.headers.get('HX-Request'):
return HttpResponse('')
messages.success(request, _('Invitation deleted.'))
return redirect('settings')
def accept_user_invite(request, token):
invite = get_object_or_404(UserInvite, token=token, accepted_at__isnull=True)
if request.user.is_authenticated:
invite.accepted_at = timezone.now()
invite.save(update_fields=['accepted_at'])
messages.success(request, _('Invitation accepted.'))
return redirect('index')
if request.method == 'POST':
form = InviteRegistrationForm(request.POST)
if form.is_valid():
user = form.save(commit=False)
if invite.email and not user.email:
user.email = invite.email
user.save()
invite.accepted_at = timezone.now()
invite.save(update_fields=['accepted_at'])
login(request, user)
messages.success(request, _('Welcome to my2dos.'))
return redirect('index')
else:
form = InviteRegistrationForm(initial={'email': invite.email})
return render(request, 'core/accept_user_invite.html', {'invite': invite, 'form': form})
@login_required
def team_list(request):
if request.method == 'POST':
team_form = TeamForm(request.POST)
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)
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')
stats = []
base_items = visible_items(request.user)
for team in teams:
team_items = base_items.filter(team=team)
stats.append({
'team': team,
'members_count': team.memberships.count(),
'items_count': team_items.count(),
'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})
@login_required
def team_detail(request, pk):
membership = get_object_or_404(TeamMembership.objects.select_related('team'), team_id=pk, user=request.user)
team = membership.team
if request.method == 'POST' and membership.role in [TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]:
invite_form = TeamInviteForm(request.POST)
if invite_form.is_valid():
invite = invite_form.save(commit=False)
invite.team = team
invite.invited_by = request.user
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)
return render(request, 'core/team_detail.html', {
'team': team,
'membership': membership,
'memberships': team.memberships.select_related('user'),
'invites': team.invites.filter(accepted_at__isnull=True),
'invite_form': TeamInviteForm(),
'items': items,
'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4,
})
@login_required
def team_accept_invite(request, token):
invite = get_object_or_404(TeamInvite.objects.select_related('team'), token=token, accepted_at__isnull=True)
TeamMembership.objects.get_or_create(team=invite.team, user=request.user, defaults={'role': TeamMembership.Role.MEMBER})
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')
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)
def user_can_edit_collection(user, collection):
return user.is_authenticated and (collection.owner_id == user.id or (collection.team_id and TeamMembership.objects.filter(team=collection.team, user=user).exists()))
def user_can_delete_collection(user, collection):
return user.is_authenticated and (collection.owner_id == user.id or (collection.team_id and TeamMembership.objects.filter(team=collection.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists()))
def collection_scope_allows_item(collection_visibility, collection_team_id, collection_owner_id, item_visibility, item_team_id, item_owner_id):
# A collection may reference a note that is already visible to a wider audience.
if item_visibility == Item.Visibility.PUBLIC:
return True
if collection_visibility == Item.Visibility.PUBLIC:
return False
if collection_visibility == Item.Visibility.TEAM:
return item_visibility == Item.Visibility.TEAM and item_team_id == collection_team_id
return item_visibility == Item.Visibility.PRIVATE and item_owner_id == collection_owner_id
def collection_item_is_compatible(collection, item):
return collection_scope_allows_item(
collection.visibility, collection.team_id, collection.owner_id,
item.visibility, item.team_id, item.owner_id,
)
def collection_subcollection_is_compatible(collection, sub_collection):
return collection_scope_allows_item(
collection.visibility, collection.team_id, collection.owner_id,
sub_collection.visibility, sub_collection.team_id, sub_collection.owner_id,
)
def collection_contains_collection(collection, target):
if collection.pk == target.pk:
return True
for section in collection.sections.select_related('sub_collection'):
if section.sub_collection and collection_contains_collection(section.sub_collection, target):
return True
return False
def collection_target_from_form(form, user):
team = form.cleaned_data.get('team')
if team:
return Item.Visibility.TEAM, team
return form.cleaned_data['visibility'], None
def collection_tag_items(collection):
items = Item.objects.filter(kind=Item.Kind.NOTE).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:
items = items.filter(Q(visibility=Item.Visibility.PUBLIC) | Q(visibility=Item.Visibility.TEAM, team=collection.team))
else:
items = items.filter(Q(visibility=Item.Visibility.PUBLIC) | Q(visibility=Item.Visibility.PRIVATE, owner=collection.owner))
for tag in collection.filter_tags.all():
items = items.filter(tags=tag)
return items.distinct().order_by('created_at', 'id')
def item_can_change_for_collection(user, item, collection):
if not user_can_edit_item(user, item):
return False
target_team_id = collection.team_id
for section in item.collection_sections.select_related('collection').exclude(collection=collection):
other = section.collection
if not collection_scope_allows_item(
other.visibility, other.team_id, other.owner_id,
collection.visibility, target_team_id, item.owner_id,
):
return False
return True
def remove_team_tags(content):
slugs = Team.objects.values_list('slug', flat=True)
for slug in slugs:
content = re.sub(rf'(?