Allow nested collections
This commit is contained in:
@@ -4,6 +4,7 @@ from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kan
|
||||
|
||||
class CollectionSectionInline(admin.TabularInline):
|
||||
model = CollectionSection
|
||||
fk_name = 'collection'
|
||||
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
@@ -222,7 +222,8 @@ class Collection(models.Model):
|
||||
|
||||
class CollectionSection(models.Model):
|
||||
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)
|
||||
position = models.PositiveIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -230,12 +231,17 @@ class CollectionSection(models.Model):
|
||||
class Meta:
|
||||
ordering = ['position', 'id']
|
||||
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'),
|
||||
]
|
||||
|
||||
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):
|
||||
|
||||
@@ -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>
|
||||
@@ -22,7 +22,7 @@
|
||||
{% if sections or tag_items %}
|
||||
<nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% if pending_item %}
|
||||
@@ -39,11 +39,11 @@
|
||||
{% for section in sections %}
|
||||
<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">
|
||||
<h2 class="h3 mb-0">{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}</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>
|
||||
<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">{% 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 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 %}<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 %}{% 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">
|
||||
<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>
|
||||
@@ -56,11 +56,16 @@
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="card item-card"><div class="card-body">
|
||||
<h2 class="h4">{% trans "Add existing note" %}</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>
|
||||
<div class="vstack gap-2">
|
||||
<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 and collections' %}"><button class="btn btn-outline-secondary">{% trans "Search" %}</button></form>
|
||||
<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 %}
|
||||
</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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils.safestring import mark_safe
|
||||
register = template.Library()
|
||||
|
||||
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'
|
||||
}
|
||||
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']}
|
||||
@@ -71,7 +71,18 @@ def markdown_title(text):
|
||||
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 '')
|
||||
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 f''
|
||||
|
||||
return markdown(FILE_RE.sub(replace, text))
|
||||
return mark_safe(offset_heading_tags(markdown(FILE_RE.sub(replace, text)), heading_offset))
|
||||
|
||||
|
||||
@register.filter
|
||||
@@ -94,6 +105,11 @@ def collection_item_markdown(item):
|
||||
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
|
||||
def is_image(file_name):
|
||||
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'))
|
||||
|
||||
@@ -341,6 +341,53 @@ class CollectionTests(TestCase):
|
||||
self.assertContains(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):
|
||||
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
|
||||
self.client.logout()
|
||||
|
||||
+55
-7
@@ -542,7 +542,7 @@ def team_accept_invite(request, token):
|
||||
|
||||
|
||||
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:
|
||||
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)
|
||||
@@ -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):
|
||||
team = form.cleaned_data.get('team')
|
||||
if team:
|
||||
@@ -639,11 +655,22 @@ def copy_item_for_collection(item, collection, owner):
|
||||
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()
|
||||
return (last_position + 1) if last_position is not None else 0
|
||||
|
||||
|
||||
def add_collection_section(collection, item):
|
||||
return CollectionSection.objects.get_or_create(
|
||||
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)
|
||||
q = request.GET.get('q', '').strip()
|
||||
candidates = Item.objects.none()
|
||||
collection_candidates = Collection.objects.none()
|
||||
if can_edit:
|
||||
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:
|
||||
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]
|
||||
collection_candidates = collection_candidates[:30]
|
||||
return {
|
||||
'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_delete': user_can_delete_collection(request.user, collection),
|
||||
'candidates': candidates,
|
||||
'collection_candidates': collection_candidates,
|
||||
'q': q,
|
||||
'pending_item': pending_item,
|
||||
'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)
|
||||
target_mode = form.cleaned_data['mode']
|
||||
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(
|
||||
not collection_scope_allows_item(
|
||||
visibility, team.id if team else None, collection.owner_id,
|
||||
section.item.visibility, section.item.team_id, section.item.owner_id,
|
||||
) for section in collection.sections.select_related('item'))
|
||||
) 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:
|
||||
form.add_error('mode', _('Remove all manual sections before changing to a tag collection.'))
|
||||
elif incompatible:
|
||||
@@ -747,7 +786,7 @@ def collection_detail(request, pk):
|
||||
collection = get_object_or_404(visible_collections(request.user), pk=pk)
|
||||
return render(request, 'core/collection_detail.html', {
|
||||
'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 [],
|
||||
'can_edit': user_can_edit_collection(request.user, collection),
|
||||
})
|
||||
@@ -782,6 +821,15 @@ def collection_manage(request, pk):
|
||||
return redirect(manage_url)
|
||||
add_collection_section(collection, item)
|
||||
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)
|
||||
if action == 'remove':
|
||||
section.delete()
|
||||
|
||||
Reference in New Issue
Block a user