Allow nested collections

This commit is contained in:
rucki
2026-08-30 18:47:35 +02:00
parent d5917d411a
commit 5793283653
9 changed files with 202 additions and 25 deletions
+1
View File
@@ -4,6 +4,7 @@ from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kan
class CollectionSectionInline(admin.TabularInline): class CollectionSectionInline(admin.TabularInline):
model = CollectionSection model = CollectionSection
fk_name = 'collection'
extra = 0 extra = 0
@@ -0,0 +1,40 @@
# Generated by Django 5.2.17 on 2026-08-30 11:23
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0018_remove_collection_tags_collection_filter_tags_and_more'),
]
operations = [
migrations.RemoveConstraint(
model_name='collectionsection',
name='unique_collection_item',
),
migrations.AddField(
model_name='collectionsection',
name='sub_collection',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='containing_sections', to='core.collection'),
),
migrations.AlterField(
model_name='collectionsection',
name='item',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='collection_sections', to='core.item'),
),
migrations.AddConstraint(
model_name='collectionsection',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('item__isnull', False), ('sub_collection__isnull', True)), models.Q(('item__isnull', True), ('sub_collection__isnull', False)), _connector='OR'), name='collection_section_exactly_one_target'),
),
migrations.AddConstraint(
model_name='collectionsection',
constraint=models.UniqueConstraint(condition=models.Q(('item__isnull', False)), fields=('collection', 'item'), name='unique_collection_item'),
),
migrations.AddConstraint(
model_name='collectionsection',
constraint=models.UniqueConstraint(condition=models.Q(('sub_collection__isnull', False)), fields=('collection', 'sub_collection'), name='unique_collection_sub_collection'),
),
]
+9 -3
View File
@@ -222,7 +222,8 @@ class Collection(models.Model):
class CollectionSection(models.Model): class CollectionSection(models.Model):
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name='sections') collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name='sections')
item = models.ForeignKey(Item, on_delete=models.PROTECT, related_name='collection_sections') item = models.ForeignKey(Item, on_delete=models.PROTECT, blank=True, null=True, related_name='collection_sections')
sub_collection = models.ForeignKey(Collection, on_delete=models.PROTECT, blank=True, null=True, related_name='containing_sections')
title = models.CharField(max_length=200, blank=True) title = models.CharField(max_length=200, blank=True)
position = models.PositiveIntegerField(default=0) position = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -230,12 +231,17 @@ class CollectionSection(models.Model):
class Meta: class Meta:
ordering = ['position', 'id'] ordering = ['position', 'id']
constraints = [ constraints = [
models.UniqueConstraint(fields=['collection', 'item'], name='unique_collection_item'), models.CheckConstraint(
check=(models.Q(item__isnull=False, sub_collection__isnull=True) | models.Q(item__isnull=True, sub_collection__isnull=False)),
name='collection_section_exactly_one_target',
),
models.UniqueConstraint(fields=['collection', 'item'], condition=models.Q(item__isnull=False), name='unique_collection_item'),
models.UniqueConstraint(fields=['collection', 'sub_collection'], condition=models.Q(sub_collection__isnull=False), name='unique_collection_sub_collection'),
models.UniqueConstraint(fields=['collection', 'position'], name='unique_collection_position'), models.UniqueConstraint(fields=['collection', 'position'], name='unique_collection_position'),
] ]
def __str__(self): def __str__(self):
return f'{self.collection}: {self.title or self.item}' return f'{self.collection}: {self.title or self.item or self.sub_collection}'
class UserPreference(models.Model): class UserPreference(models.Model):
@@ -0,0 +1,14 @@
{% load markdown_extras %}
<section id="section-{{ section.id }}" class="collection-flow-section{% if nested %} ms-4 mt-4{% endif %}">
{% if section.item %}
{% if section.title %}{% if nested %}<h3>{{ section.title }}</h3>{% else %}<h2>{{ section.title }}</h2>{% endif %}{% endif %}
{% if nested %}{{ section.item|collection_sub_item_markdown }}{% else %}{{ section.item|collection_item_markdown }}{% endif %}
{% if section.item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}
{% elif section.sub_collection %}
{% if nested %}<h3>{{ section.title|default:section.sub_collection.title }}</h3>{% else %}<h2>{{ section.title|default:section.sub_collection.title }}</h2>{% endif %}
{% if section.sub_collection.description %}<div class="content lead mt-3">{{ section.sub_collection.description|markdown }}</div>{% endif %}
{% for child_section in section.sub_collection.sections.all %}
{% include 'core/_collection_section_read.html' with section=child_section nested=True %}
{% endfor %}
{% endif %}
</section>
+2 -2
View File
@@ -22,7 +22,7 @@
{% if sections or tag_items %} {% if sections or tag_items %}
<nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}"> <nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}">
<div class="small fw-semibold text-uppercase text-muted mb-2">{% trans "Contents" %}</div> <div class="small fw-semibold text-uppercase text-muted mb-2">{% trans "Contents" %}</div>
<ol class="mb-0">{% if collection.mode == 'tags' %}{% for item in tag_items %}<li><a href="#item-{{ item.id }}">{{ item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}{% else %}{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}{% endif %}</ol> <ol class="mb-0">{% if collection.mode == 'tags' %}{% for item in tag_items %}<li><a href="#item-{{ item.id }}">{{ item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}{% else %}{% for section in sections %}<li><a href="#section-{{ section.id }}">{% if section.item %}{{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}{% else %}{{ section.title|default:section.sub_collection.title|truncatechars:90 }}{% endif %}</a></li>{% endfor %}{% endif %}</ol>
</nav> </nav>
{% endif %} {% endif %}
@@ -30,7 +30,7 @@
{% if collection.mode == 'tags' %} {% if collection.mode == 'tags' %}
{% for item in tag_items %}<section id="item-{{ item.id }}" class="collection-flow-section">{{ item|collection_item_markdown }}{% if item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "No notes match the selected tags yet." %}</div>{% endfor %} {% for item in tag_items %}<section id="item-{{ item.id }}" class="collection-flow-section">{{ item|collection_item_markdown }}{% if item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "No notes match the selected tags yet." %}</div>{% endfor %}
{% else %} {% else %}
{% for section in sections %}<section id="section-{{ section.id }}" class="collection-flow-section">{% if section.title %}<h2>{{ section.title }}</h2>{% endif %}{{ section.item|collection_item_markdown }}{% if section.item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %} {% for section in sections %}{% include 'core/_collection_section_read.html' with section=section %}{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
{% endif %} {% endif %}
</div> </div>
</article> </article>
+13 -8
View File
@@ -18,7 +18,7 @@
{% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %} {% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %}
{% if sections %} {% if sections %}
<div class="card item-card mb-4"><div class="card-body"><div class="fw-semibold mb-2">{% trans "Contents" %}</div><ol class="mb-0">{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}</a></li>{% endfor %}</ol></div></div> <div class="card item-card mb-4"><div class="card-body"><div class="fw-semibold mb-2">{% trans "Contents" %}</div><ol class="mb-0">{% for section in sections %}<li><a href="#section-{{ section.id }}">{% if section.item %}{{ section.title|default:section.item.content|markdown_title|truncatechars:90 }}{% else %}{{ section.title|default:section.sub_collection.title|truncatechars:90 }}{% endif %}</a></li>{% endfor %}</ol></div></div>
{% endif %} {% endif %}
{% if pending_item %} {% if pending_item %}
@@ -39,11 +39,11 @@
{% for section in sections %} {% for section in sections %}
<section id="section-{{ section.id }}" class="card item-card"><div class="card-body"> <section id="section-{{ section.id }}" class="card item-card"><div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3"> <div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<h2 class="h3 mb-0">{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}</h2> <h2 class="h3 mb-0">{% if section.item %}{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}{% else %}{{ section.title|default:section.sub_collection.title|truncatechars:100 }}{% endif %}</h2>
<div class="d-flex gap-2 align-items-center"><a class="btn btn-sm btn-outline-secondary" href="{% url 'item_editor' section.item.id %}?next={% url 'collection_manage' collection.id %}">{% trans "Edit note" %}</a><a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a></div> <div class="d-flex gap-2 align-items-center">{% if section.item %}<a class="btn btn-sm btn-outline-secondary" href="{% url 'item_editor' section.item.id %}?next={% url 'collection_manage' collection.id %}">{% trans "Edit note" %}</a><a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a>{% else %}<a class="btn btn-sm btn-outline-secondary" href="{% url 'collection_manage' section.sub_collection.id %}">{% trans "Edit collection" %}</a><a class="small text-muted" href="{{ section.sub_collection.get_absolute_url }}">#{{ section.sub_collection.id }}</a>{% endif %}</div>
</div> </div>
<div class="content">{{ section.item|item_markdown }}</div> {% if section.item %}<div class="content">{{ section.item|item_markdown }}</div>
{% if section.item.comment %}<div class="content text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %} {% if section.item.comment %}<div class="content text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}{% else %}<div class="text-muted">{% trans "Nested collection" %}: {{ section.sub_collection.title }}</div>{% endif %}
{% if can_edit %}<div class="border-top mt-4 pt-3 d-flex align-items-center gap-2"> {% if can_edit %}<div class="border-top mt-4 pt-3 d-flex align-items-center gap-2">
<form method="post" class="d-flex gap-2 flex-grow-1">{% csrf_token %}<input type="hidden" name="action" value="title"><input type="hidden" name="section_id" value="{{ section.id }}"><input class="form-control form-control-sm" name="title" value="{{ section.title }}" placeholder="{% trans 'Optional section title' %}"><button class="btn btn-sm btn-outline-secondary">{% trans "Save title" %}</button></form> <form method="post" class="d-flex gap-2 flex-grow-1">{% csrf_token %}<input type="hidden" name="action" value="title"><input type="hidden" name="section_id" value="{{ section.id }}"><input class="form-control form-control-sm" name="title" value="{{ section.title }}" placeholder="{% trans 'Optional section title' %}"><button class="btn btn-sm btn-outline-secondary">{% trans "Save title" %}</button></form>
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="up"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move up' %}">↑</button></form> <form method="post">{% csrf_token %}<input type="hidden" name="action" value="up"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move up' %}">↑</button></form>
@@ -56,11 +56,16 @@
{% if can_edit %} {% if can_edit %}
<div class="card item-card"><div class="card-body"> <div class="card item-card"><div class="card-body">
<h2 class="h4">{% trans "Add existing note" %}</h2> <h2 class="h4">{% trans "Add existing note or collection" %}</h2>
<form method="get" class="input-group mb-3"><input class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search notes' %}"><button class="btn btn-outline-secondary">{% trans "Search" %}</button></form> <form method="get" class="input-group mb-3"><input class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search notes and collections' %}"><button class="btn btn-outline-secondary">{% trans "Search" %}</button></form>
<div class="vstack gap-2"> <h3 class="h6">{% trans "Notes" %}</h3>
<div class="vstack gap-2 mb-4">
{% for item in candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3"><div><div>{{ item.content|truncatechars:180 }}</div><div class="small text-muted">#{{ item.id }} · {{ item.get_visibility_display }}{% if item.team %} · {{ item.team.name }}{% endif %}</div></div><form method="post">{% csrf_token %}<input type="hidden" name="action" value="add"><input type="hidden" name="item_id" value="{{ item.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form></div>{% empty %}<div class="text-muted">{% trans "No matching notes." %}</div>{% endfor %} {% for item in candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3"><div><div>{{ item.content|truncatechars:180 }}</div><div class="small text-muted">#{{ item.id }} · {{ item.get_visibility_display }}{% if item.team %} · {{ item.team.name }}{% endif %}</div></div><form method="post">{% csrf_token %}<input type="hidden" name="action" value="add"><input type="hidden" name="item_id" value="{{ item.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form></div>{% empty %}<div class="text-muted">{% trans "No matching notes." %}</div>{% endfor %}
</div> </div>
<h3 class="h6">{% trans "Collections" %}</h3>
<div class="vstack gap-2">
{% for candidate in collection_candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3"><div><div>{{ candidate.title }}</div><div class="small text-muted">#{{ candidate.id }} · {{ candidate.get_visibility_display }}{% if candidate.team %} · {{ candidate.team.name }}{% endif %}</div></div><form method="post">{% csrf_token %}<input type="hidden" name="action" value="add_collection"><input type="hidden" name="collection_id" value="{{ candidate.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form></div>{% empty %}<div class="text-muted">{% trans "No matching collections." %}</div>{% endfor %}
</div>
</div></div> </div></div>
{% endif %} {% endif %}
</div> </div>
+19 -3
View File
@@ -7,7 +7,7 @@ from django.utils.safestring import mark_safe
register = template.Library() register = template.Library()
ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | { ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | {
'p', 'pre', 'code', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'p', 'pre', 'code', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li',
'strong', 'em', 'u', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'img' 'strong', 'em', 'u', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'img'
} }
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']} ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']}
@@ -71,7 +71,18 @@ def markdown_title(text):
return '' return ''
def render_item_markdown(item, hide_tags=False): def offset_heading_tags(html, offset=0):
if not offset:
return html
def replace(match):
slash, level = match.groups()
return f'<{slash}h{min(6, int(level) + offset)}>'
return re.sub(r'<(/?)h([1-6])>', replace, str(html))
def render_item_markdown(item, hide_tags=False, heading_offset=0):
text = strip_tags_outside_code(item.content) if hide_tags else (item.content or '') text = strip_tags_outside_code(item.content) if hide_tags else (item.content or '')
attachments = {str(a.id): a for a in item.attachments.all()} attachments = {str(a.id): a for a in item.attachments.all()}
@@ -81,7 +92,7 @@ def render_item_markdown(item, hide_tags=False):
return match.group(0) return match.group(0)
return f'![{attachment.file.name}]({attachment.file.url})' return f'![{attachment.file.name}]({attachment.file.url})'
return markdown(FILE_RE.sub(replace, text)) return mark_safe(offset_heading_tags(markdown(FILE_RE.sub(replace, text)), heading_offset))
@register.filter @register.filter
@@ -94,6 +105,11 @@ def collection_item_markdown(item):
return render_item_markdown(item, hide_tags=True) return render_item_markdown(item, hide_tags=True)
@register.filter
def collection_sub_item_markdown(item):
return render_item_markdown(item, hide_tags=True, heading_offset=1)
@register.filter @register.filter
def is_image(file_name): def is_image(file_name):
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg')) return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'))
+47
View File
@@ -341,6 +341,53 @@ class CollectionTests(TestCase):
self.assertContains(response, '>Section</') self.assertContains(response, '>Section</')
self.assertNotContains(response, '>## Section</') self.assertNotContains(response, '>## Section</')
def test_collection_can_include_collection(self):
parent = Collection.objects.create(owner=self.user, title='Parent')
child = Collection.objects.create(owner=self.user, title='Child')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Child note #internal')
CollectionSection.objects.create(collection=child, item=note, position=0)
manage_url = reverse('collection_manage', args=[parent.id])
response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': child.id})
self.assertRedirects(response, manage_url)
self.assertTrue(parent.sections.filter(sub_collection=child).exists())
response = self.client.get(parent.get_absolute_url())
self.assertContains(response, '<h2>Child</h2>', html=True)
self.assertContains(response, '<h3>Child note</h3>', html=True)
self.assertNotContains(response, '#internal')
def test_collection_loop_is_rejected(self):
parent = Collection.objects.create(owner=self.user, title='Parent')
child = Collection.objects.create(owner=self.user, title='Child')
CollectionSection.objects.create(collection=parent, sub_collection=child, position=0)
manage_url = reverse('collection_manage', args=[child.id])
response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': parent.id}, follow=True)
self.assertContains(response, 'collection loop')
self.assertFalse(child.sections.filter(sub_collection=parent).exists())
def test_public_collection_cannot_include_private_collection(self):
parent = Collection.objects.create(owner=self.user, title='Parent', visibility=Item.Visibility.PUBLIC)
child = Collection.objects.create(owner=self.user, title='Child', visibility=Item.Visibility.PRIVATE)
manage_url = reverse('collection_manage', args=[parent.id])
response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': child.id}, follow=True)
self.assertContains(response, 'incompatible visibility')
self.assertFalse(parent.sections.filter(sub_collection=child).exists())
def test_private_collection_can_include_public_collection(self):
parent = Collection.objects.create(owner=self.user, title='Parent', visibility=Item.Visibility.PRIVATE)
child = Collection.objects.create(owner=self.user, title='Child', visibility=Item.Visibility.PUBLIC)
manage_url = reverse('collection_manage', args=[parent.id])
response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': child.id})
self.assertRedirects(response, manage_url)
self.assertTrue(parent.sections.filter(sub_collection=child).exists())
def test_public_collection_is_visible_without_login(self): def test_public_collection_is_visible_without_login(self):
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC) collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
self.client.logout() self.client.logout()
+57 -9
View File
@@ -542,7 +542,7 @@ def team_accept_invite(request, token):
def visible_collections(user): def visible_collections(user):
qs = Collection.objects.select_related('owner', 'team').prefetch_related('filter_tags', 'sections__item') qs = Collection.objects.select_related('owner', 'team').prefetch_related('filter_tags', 'sections__item', 'sections__sub_collection')
if user.is_authenticated: 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(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(visibility=Item.Visibility.PUBLIC)
@@ -574,6 +574,22 @@ def collection_item_is_compatible(collection, item):
) )
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): def collection_target_from_form(form, user):
team = form.cleaned_data.get('team') team = form.cleaned_data.get('team')
if team: if team:
@@ -639,11 +655,22 @@ def copy_item_for_collection(item, collection, owner):
return adapt_item_for_collection(copied, collection) return adapt_item_for_collection(copied, collection)
def add_collection_section(collection, item): def next_collection_section_position(collection):
last_position = collection.sections.order_by('-position').values_list('position', flat=True).first() last_position = collection.sections.order_by('-position').values_list('position', flat=True).first()
return (last_position + 1) if last_position is not None else 0
def add_collection_section(collection, item):
return CollectionSection.objects.get_or_create( return CollectionSection.objects.get_or_create(
collection=collection, item=item, collection=collection, item=item,
defaults={'position': (last_position + 1) if last_position is not None else 0}, defaults={'position': next_collection_section_position(collection)},
)
def add_collection_subcollection(collection, sub_collection):
return CollectionSection.objects.get_or_create(
collection=collection, sub_collection=sub_collection,
defaults={'position': next_collection_section_position(collection)},
) )
@@ -657,17 +684,23 @@ def collection_detail_context(request, collection, pending_item=None):
can_edit = user_can_edit_collection(request.user, collection) can_edit = user_can_edit_collection(request.user, collection)
q = request.GET.get('q', '').strip() q = request.GET.get('q', '').strip()
candidates = Item.objects.none() candidates = Item.objects.none()
collection_candidates = Collection.objects.none()
if can_edit: if can_edit:
candidates = editable_collection_notes(request.user).exclude(collection_sections__collection=collection) candidates = editable_collection_notes(request.user).exclude(collection_sections__collection=collection)
collection_candidates = visible_collections(request.user).filter(mode=Collection.Mode.MANUAL).exclude(pk=collection.pk).exclude(containing_sections__collection=collection)
collection_candidates = [candidate for candidate in collection_candidates if collection_subcollection_is_compatible(collection, candidate) and not collection_contains_collection(candidate, collection)]
if q: if q:
candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct() candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
collection_candidates = [candidate for candidate in collection_candidates if q.lower() in candidate.title.lower()]
candidates = candidates[:30] candidates = candidates[:30]
collection_candidates = collection_candidates[:30]
return { return {
'collection': collection, 'collection': collection,
'sections': collection.sections.select_related('item').prefetch_related('item__tags', 'item__attachments'), 'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection'),
'can_edit': can_edit, 'can_edit': can_edit,
'can_delete': user_can_delete_collection(request.user, collection), 'can_delete': user_can_delete_collection(request.user, collection),
'candidates': candidates, 'candidates': candidates,
'collection_candidates': collection_candidates,
'q': q, 'q': q,
'pending_item': pending_item, 'pending_item': pending_item,
'pending_can_convert': pending_item and item_can_change_for_collection(request.user, pending_item, collection), 'pending_can_convert': pending_item and item_can_change_for_collection(request.user, pending_item, collection),
@@ -718,10 +751,16 @@ def collection_edit(request, pk):
visibility, team = collection_target_from_form(form, request.user) visibility, team = collection_target_from_form(form, request.user)
target_mode = form.cleaned_data['mode'] target_mode = form.cleaned_data['mode']
has_manual_sections = collection.mode == Collection.Mode.MANUAL and collection.sections.exists() has_manual_sections = collection.mode == Collection.Mode.MANUAL and collection.sections.exists()
incompatible = target_mode == Collection.Mode.MANUAL and any(not collection_scope_allows_item( incompatible = target_mode == Collection.Mode.MANUAL and any(
visibility, team.id if team else None, collection.owner_id, not collection_scope_allows_item(
section.item.visibility, section.item.team_id, section.item.owner_id, visibility, team.id if team else None, collection.owner_id,
) for section in collection.sections.select_related('item')) section.item.visibility, section.item.team_id, section.item.owner_id,
) if section.item else not collection_scope_allows_item(
visibility, team.id if team else None, collection.owner_id,
section.sub_collection.visibility, section.sub_collection.team_id, section.sub_collection.owner_id,
)
for section in collection.sections.select_related('item', 'sub_collection')
)
if target_mode == Collection.Mode.TAGS and has_manual_sections: if target_mode == Collection.Mode.TAGS and has_manual_sections:
form.add_error('mode', _('Remove all manual sections before changing to a tag collection.')) form.add_error('mode', _('Remove all manual sections before changing to a tag collection.'))
elif incompatible: elif incompatible:
@@ -747,7 +786,7 @@ 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), pk=pk)
return render(request, 'core/collection_detail.html', { return render(request, 'core/collection_detail.html', {
'collection': collection, 'collection': collection,
'sections': collection.sections.select_related('item').prefetch_related('item__tags', 'item__attachments') if collection.mode == Collection.Mode.MANUAL else [], 'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection') if collection.mode == Collection.Mode.MANUAL else [],
'tag_items': collection_tag_items(collection) if collection.mode == Collection.Mode.TAGS else [], 'tag_items': collection_tag_items(collection) if collection.mode == Collection.Mode.TAGS else [],
'can_edit': user_can_edit_collection(request.user, collection), 'can_edit': user_can_edit_collection(request.user, collection),
}) })
@@ -782,6 +821,15 @@ def collection_manage(request, pk):
return redirect(manage_url) return redirect(manage_url)
add_collection_section(collection, item) add_collection_section(collection, item)
return redirect(manage_url) return redirect(manage_url)
if action == 'add_collection':
sub_collection = get_object_or_404(visible_collections(request.user), pk=request.POST.get('collection_id'), mode=Collection.Mode.MANUAL)
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):
messages.error(request, _('This collection has an incompatible visibility.'))
else:
add_collection_subcollection(collection, sub_collection)
return redirect(manage_url)
section = get_object_or_404(CollectionSection, pk=request.POST.get('section_id'), collection=collection) section = get_object_or_404(CollectionSection, pk=request.POST.get('section_id'), collection=collection)
if action == 'remove': if action == 'remove':
section.delete() section.delete()