From 00c1eba3eba55ed7f0cc7cca544a84f98817c82c Mon Sep 17 00:00:00 2001 From: Nishikawa Koharu Date: Fri, 17 Apr 2026 14:05:21 +0900 Subject: [PATCH 1/4] Prevent errors caused by duplicate wiki pages --- addons/wiki/models.py | 11 ++++++----- addons/wiki/views.py | 29 +++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/addons/wiki/models.py b/addons/wiki/models.py index bff16c53d94..47618f9329c 100644 --- a/addons/wiki/models.py +++ b/addons/wiki/models.py @@ -260,11 +260,12 @@ def create_for_node(self, node, name, content, auth, parent=None, is_wiki_import def get_for_node(self, node, name=None, id=None): if name: - try: - name = (name or '').strip() - return WikiPage.objects.get(page_name__iexact=name, deleted__isnull=True, node=node) - except WikiPage.DoesNotExist: - return None + name = (name or '').strip() + return WikiPage.objects.filter( + page_name__iexact=name, + deleted__isnull=True, + node=node, + ).order_by('created', 'id').first() return WikiPage.load(id) def get_for_child_nodes(self, node, parent=None): diff --git a/addons/wiki/views.py b/addons/wiki/views.py index 2e38ccd7f35..e3df216196a 100644 --- a/addons/wiki/views.py +++ b/addons/wiki/views.py @@ -120,7 +120,26 @@ def _get_wiki_versions(node, name, anonymous=False): for version in versions ] +def _iter_wiki_latest_deduped_by_canonical_name(node, wiki_version_qs): + """Yield WikiVersion rows whose wiki_page matches get_for_node for that name. + + Duplicate active WikiPage rows with the same page_name (case-insensitive) are + hidden from menus; the canonical row matches WikiPage.objects.get_for_node. + """ + canonical_cache = {} + for page in wiki_version_qs: + wp = page.wiki_page + key = wp.page_name.lower() + if key not in canonical_cache: + canonical_cache[key] = WikiPage.objects.get_for_node(node, wp.page_name) + canonical = canonical_cache[key] + if canonical is None or canonical.id != wp.id: + continue + yield page + def _get_wiki_pages_latest(node): + qs = WikiPage.objects.get_wiki_pages_latest(node).order_by( + F('wiki_page__sort_order'), F('name')) return [ { 'name': page.wiki_page.page_name, @@ -130,10 +149,12 @@ def _get_wiki_pages_latest(node): 'wiki_content': _wiki_page_content(page.wiki_page.page_name, node=node), 'sort_order': page.wiki_page.sort_order } - for page in WikiPage.objects.get_wiki_pages_latest(node).order_by(F('wiki_page__sort_order'), F('name')) + for page in _iter_wiki_latest_deduped_by_canonical_name(node, qs) ] def _get_wiki_child_pages_latest(node, parent): + qs = WikiPage.objects.get_wiki_child_pages_latest(node, parent).order_by( + F('wiki_page__sort_order'), F('name')) return [ { 'name': page.wiki_page.page_name, @@ -143,7 +164,7 @@ def _get_wiki_child_pages_latest(node, parent): 'wiki_content': _wiki_page_content(page.wiki_page.page_name, node=node), 'sort_order': page.wiki_page.sort_order } - for page in WikiPage.objects.get_wiki_child_pages_latest(node, parent).order_by(F('wiki_page__sort_order'), F('name')) + for page in _iter_wiki_latest_deduped_by_canonical_name(node, qs) ] def _get_wiki_api_urls(node, name, additional_urls=None): @@ -1357,9 +1378,13 @@ def _get_sorted_list(sorted_data, parent_wiki_id): return id_list, sort_list, parent_wiki_id_list def _bulk_update_wiki_sort(node, sort_id_list, sort_num_list, parent_wiki_id_list): + # Tree payload omits duplicate-name rows (see _iter_wiki_latest_deduped_by_canonical_name); skip those. + sort_ids = set(sort_id_list) wiki_pages = node.wikis.filter(deleted__isnull=True).exclude(page_name='home') for page in wiki_pages: + if page._primary_key not in sort_ids: + continue idx = sort_id_list.index(page._primary_key) sort_order_number = sort_num_list[idx] parent_wiki_id = parent_wiki_id_list[idx] From 7ffa8f92940d22df55f9ee0ade864a06e1da9dc8 Mon Sep 17 00:00:00 2001 From: Nishikawa Koharu Date: Wed, 22 Apr 2026 13:27:38 +0900 Subject: [PATCH 2/4] one_select --- addons/wiki/views.py | 47 ++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/addons/wiki/views.py b/addons/wiki/views.py index e3df216196a..e7a26cfae5f 100644 --- a/addons/wiki/views.py +++ b/addons/wiki/views.py @@ -26,6 +26,7 @@ from celery.contrib.abortable import AbortableAsyncResult from flask import request from flask_babel import lazy_gettext as _ +from django.db.models import Func from django.db.models.expressions import F from django_bulk_update.helper import bulk_update from django.core.exceptions import ObjectDoesNotExist @@ -120,26 +121,29 @@ def _get_wiki_versions(node, name, anonymous=False): for version in versions ] -def _iter_wiki_latest_deduped_by_canonical_name(node, wiki_version_qs): - """Yield WikiVersion rows whose wiki_page matches get_for_node for that name. +def _sort_wiki_versions_for_menu(wiki_versions): + """sort_order, then page name (null sort_order last). After DISTINCT ON dedupe.""" + wiki_versions.sort( + key=lambda wv: (wv.wiki_page.sort_order is None, wv.wiki_page.sort_order, wv.wiki_page.page_name or '')) - Duplicate active WikiPage rows with the same page_name (case-insensitive) are - hidden from menus; the canonical row matches WikiPage.objects.get_for_node. + +def _wiki_versions_latest_deduped_by_canonical_name(wiki_version_qs): + """One WikiVersion per LOWER(name), same as get_for_node (min wiki_page__created, then id). + + get_wiki_pages_latest annotates `name=F('wiki_page__page_name')`. PostgreSQL DISTINCT ON. """ - canonical_cache = {} - for page in wiki_version_qs: - wp = page.wiki_page - key = wp.page_name.lower() - if key not in canonical_cache: - canonical_cache[key] = WikiPage.objects.get_for_node(node, wp.page_name) - canonical = canonical_cache[key] - if canonical is None or canonical.id != wp.id: - continue - yield page + q = ( + wiki_version_qs + .annotate(name_lower=Func(F('name'), function='LOWER')) + .order_by('name_lower', 'wiki_page__created', 'wiki_page__id') + .distinct('name_lower') + ) + return list(q) def _get_wiki_pages_latest(node): - qs = WikiPage.objects.get_wiki_pages_latest(node).order_by( - F('wiki_page__sort_order'), F('name')) + base = WikiPage.objects.get_wiki_pages_latest(node) + pages = _wiki_versions_latest_deduped_by_canonical_name(base) + _sort_wiki_versions_for_menu(pages) return [ { 'name': page.wiki_page.page_name, @@ -149,12 +153,13 @@ def _get_wiki_pages_latest(node): 'wiki_content': _wiki_page_content(page.wiki_page.page_name, node=node), 'sort_order': page.wiki_page.sort_order } - for page in _iter_wiki_latest_deduped_by_canonical_name(node, qs) + for page in pages ] def _get_wiki_child_pages_latest(node, parent): - qs = WikiPage.objects.get_wiki_child_pages_latest(node, parent).order_by( - F('wiki_page__sort_order'), F('name')) + base = WikiPage.objects.get_wiki_child_pages_latest(node, parent) + pages = _wiki_versions_latest_deduped_by_canonical_name(base) + _sort_wiki_versions_for_menu(pages) return [ { 'name': page.wiki_page.page_name, @@ -164,7 +169,7 @@ def _get_wiki_child_pages_latest(node, parent): 'wiki_content': _wiki_page_content(page.wiki_page.page_name, node=node), 'sort_order': page.wiki_page.sort_order } - for page in _iter_wiki_latest_deduped_by_canonical_name(node, qs) + for page in pages ] def _get_wiki_api_urls(node, name, additional_urls=None): @@ -1378,7 +1383,7 @@ def _get_sorted_list(sorted_data, parent_wiki_id): return id_list, sort_list, parent_wiki_id_list def _bulk_update_wiki_sort(node, sort_id_list, sort_num_list, parent_wiki_id_list): - # Tree payload omits duplicate-name rows (see _iter_wiki_latest_deduped_by_canonical_name); skip those. + # Tree payload omits duplicate-name rows (see _wiki_versions_latest_deduped_by_canonical_name); skip those. sort_ids = set(sort_id_list) wiki_pages = node.wikis.filter(deleted__isnull=True).exclude(page_name='home') From 0204b4d9dd699c6ed5c466af909535ae90d2fdc4 Mon Sep 17 00:00:00 2001 From: Nishikawa Koharu Date: Wed, 22 Apr 2026 15:04:02 +0900 Subject: [PATCH 3/4] one_select_correction --- addons/wiki/views.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/addons/wiki/views.py b/addons/wiki/views.py index e7a26cfae5f..d1ca3171ad0 100644 --- a/addons/wiki/views.py +++ b/addons/wiki/views.py @@ -26,7 +26,6 @@ from celery.contrib.abortable import AbortableAsyncResult from flask import request from flask_babel import lazy_gettext as _ -from django.db.models import Func from django.db.models.expressions import F from django_bulk_update.helper import bulk_update from django.core.exceptions import ObjectDoesNotExist @@ -130,15 +129,22 @@ def _sort_wiki_versions_for_menu(wiki_versions): def _wiki_versions_latest_deduped_by_canonical_name(wiki_version_qs): """One WikiVersion per LOWER(name), same as get_for_node (min wiki_page__created, then id). - get_wiki_pages_latest annotates `name=F('wiki_page__page_name')`. PostgreSQL DISTINCT ON. + get_wiki_pages_latest annotates `name=F('wiki_page__page_name')`. + Keep DB access to a single query and dedupe in Python for older Django versions + where annotate() + distinct(fields) is not supported. """ - q = ( - wiki_version_qs - .annotate(name_lower=Func(F('name'), function='LOWER')) - .order_by('name_lower', 'wiki_page__created', 'wiki_page__id') - .distinct('name_lower') - ) - return list(q) + q = wiki_version_qs.order_by('wiki_page__created', 'wiki_page__id') + + seen_names = set() + deduped = [] + for wiki_version in q: + canonical_name = (wiki_version.name or '').lower() + if canonical_name in seen_names: + continue + seen_names.add(canonical_name) + deduped.append(wiki_version) + + return deduped def _get_wiki_pages_latest(node): base = WikiPage.objects.get_wiki_pages_latest(node) From fb6b164e6922b5d40b8be241c12ad5ea6a2634b3 Mon Sep 17 00:00:00 2001 From: Nishikawa Koharu Date: Wed, 22 Apr 2026 16:09:54 +0900 Subject: [PATCH 4/4] Remove unused import --- addons/wiki/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/addons/wiki/views.py b/addons/wiki/views.py index d1ca3171ad0..5b999410d22 100644 --- a/addons/wiki/views.py +++ b/addons/wiki/views.py @@ -26,7 +26,6 @@ from celery.contrib.abortable import AbortableAsyncResult from flask import request from flask_babel import lazy_gettext as _ -from django.db.models.expressions import F from django_bulk_update.helper import bulk_update from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone