Skip to content

Commit 439a7c7

Browse files
authored
Merge pull request #1361 from makeabilitylab/1352-merge-keywords
Keyword dedup: merge action, duplicate-keywords health check, whitespace ward (#1352)
2 parents 2d25bde + 513939e commit 439a7c7

9 files changed

Lines changed: 503 additions & 4 deletions

File tree

website/admin/data_health/checks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@
1212
project_leadership,
1313
position_integrity,
1414
news_health,
15+
duplicate_keywords,
1516
)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""
2+
Data-health check: keywords that collapse to the same normalized text.
3+
4+
Keywords are free-text with no uniqueness constraint, so case/whitespace
5+
variants coexist (e.g. ``Speech`` / ``speech`` / ``Speech ``) and fragment the
6+
public keyword pages. This check *finds* those near-duplicate clusters; the
7+
``Merge selected keywords`` action on the Keyword admin changelist (#1352) is
8+
the tool to *fix* them. Strictly read-only.
9+
10+
One row is emitted per keyword in any cluster of two or more keywords that
11+
share a normalized key (``.strip().casefold()``), with per-model usage counts
12+
to support the merge decision (which variant to keep as the target).
13+
"""
14+
15+
from collections import defaultdict
16+
from urllib.parse import urlencode
17+
18+
from django.urls import reverse
19+
20+
from website.admin.data_health.registry import HealthCheck, register_check
21+
from website.models import (Keyword, Publication, Talk, Poster, Grant,
22+
Project, ProjectUmbrella)
23+
24+
# Reverse accessor on Keyword for each model that holds a `keywords` M2M.
25+
# (Video is not an Artifact, so it has no keywords.) Keep this in lockstep with
26+
# KEYWORD_USERS in website/admin/keyword_admin.py.
27+
KEYWORD_REVERSE_ACCESSORS = (
28+
('publication_count', 'publication_set'),
29+
('talk_count', 'talk_set'),
30+
('poster_count', 'poster_set'),
31+
('grant_count', 'grant_set'),
32+
('project_count', 'project_set'),
33+
('project_umbrella_count', 'projectumbrella_set'),
34+
)
35+
36+
37+
def normalize_keyword(text):
38+
"""Cluster key for near-duplicate detection: trim + case-fold.
39+
40+
casefold() is the aggressive, Unicode-aware lower() — so ``Speech``,
41+
``speech``, and ``Speech `` all collapse to the same key.
42+
"""
43+
return (text or '').strip().casefold()
44+
45+
46+
@register_check
47+
class DuplicateKeywordsCheck(HealthCheck):
48+
slug = 'duplicate-keywords'
49+
title = 'Duplicate keywords (same normalized text)'
50+
description = (
51+
'Keywords that collapse to the same text after trimming whitespace and '
52+
'folding case (e.g. "Speech" / "speech" / "Speech ") — candidates for '
53+
'the "Merge selected keywords" action on the Keyword admin (#1352).'
54+
)
55+
group = 'Artifacts'
56+
columns = [
57+
'cluster_key', 'id', 'keyword',
58+
'publication_count', 'talk_count', 'poster_count', 'grant_count',
59+
'project_count', 'project_umbrella_count', 'total_uses',
60+
]
61+
62+
def get_rows(self):
63+
# Group every keyword by its normalized key.
64+
clusters = defaultdict(list)
65+
for kw in Keyword.objects.all():
66+
clusters[normalize_keyword(kw.keyword)].append(kw)
67+
68+
rows = []
69+
for key, members in clusters.items():
70+
if len(members) < 2:
71+
continue # only multi-keyword clusters are near-duplicates
72+
for kw in members:
73+
rows.append(self._row(key, kw))
74+
75+
# Stable, scannable ordering: cluster together, then by id.
76+
rows.sort(key=lambda r: (r['cluster_key'], r['id']))
77+
return rows
78+
79+
def row_link(self, row):
80+
"""Deep-link each row to the Keyword changelist pre-filtered to its
81+
cluster, so the editor lands on exactly the variants to select and run
82+
the "Merge selected keywords" action on — wiring this finder to its
83+
fixer. The admin search is icontains on ``keyword``, so the folded
84+
cluster key matches every casing variant in the cluster.
85+
"""
86+
url = reverse('admin:website_keyword_changelist')
87+
return ('Merge in admin →', f"{url}?{urlencode({'q': row['cluster_key']})}")
88+
89+
def _row(self, key, kw):
90+
counts = {col: getattr(kw, accessor).count()
91+
for col, accessor in KEYWORD_REVERSE_ACCESSORS}
92+
return {
93+
'cluster_key': key,
94+
'id': kw.pk,
95+
'keyword': kw.keyword,
96+
**counts,
97+
'total_uses': sum(counts.values()),
98+
}

website/admin/data_health/registry.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ def get_rows(self):
3939
"""Return a list of row dicts (read-only). Override in subclasses."""
4040
raise NotImplementedError
4141

42+
def row_link(self, row):
43+
"""Optional: a ``(label, url)`` pair to act on ``row`` from the detail
44+
page, or ``None``. Lets a check wire its read-only findings to a fixer
45+
elsewhere in the admin (e.g. the keyword-merge changelist). Default: no
46+
link. Only affects the on-screen table — the CSV export is unchanged.
47+
"""
48+
return None
49+
4250
def count(self):
4351
"""Number of flagged rows. Override for a cheaper count if available."""
4452
return len(self.get_rows())

website/admin/data_health/views.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,19 @@ def detail(request, check_slug):
5353
if check is None:
5454
raise Http404("Unknown data-health check.")
5555
# Flatten dict rows into cell lists aligned to the column order so the
56-
# template can render them without a dynamic dict-lookup filter.
56+
# template can render them without a dynamic dict-lookup filter. Each row
57+
# also carries an optional (label, url) action link the check may provide.
5758
columns = check.columns
58-
rows = [[row.get(col, '') for col in columns] for row in check.get_rows()]
59+
rows = [
60+
{'cells': [row.get(col, '') for col in columns],
61+
'link': check.row_link(row)}
62+
for row in check.get_rows()
63+
]
5964
context = _admin_context(request, title=check.title)
6065
context['check'] = check
6166
context['columns'] = columns
6267
context['rows'] = rows
68+
context['has_links'] = any(row['link'] for row in rows)
6369
return render(request, 'admin/data_health/detail.html', context)
6470

6571

website/admin/keyword_admin.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from django.contrib import admin
1+
from django.contrib import admin, messages
2+
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
3+
from django.db import transaction
4+
from django.shortcuts import render
25
from website.models import (Keyword, Publication, Talk, Poster, Grant,
36
Project, ProjectUmbrella)
47
from website.admin.utils import related_count_subquery
@@ -10,6 +13,13 @@
1013
# Artifact, so it has no keywords.)
1114
KEYWORD_USERS = (Publication, Talk, Poster, Grant, Project, ProjectUmbrella)
1215

16+
# Reverse accessor on Keyword for each model in KEYWORD_USERS — the relations a
17+
# merge must walk to reattach the target keyword before deleting the sources.
18+
KEYWORD_REVERSE_ACCESSORS = (
19+
'publication_set', 'talk_set', 'poster_set',
20+
'grant_set', 'project_set', 'projectumbrella_set',
21+
)
22+
1323

1424
class KeywordUsageFilter(admin.SimpleListFilter):
1525
"""Filter keywords by whether anything references them — the core
@@ -29,6 +39,32 @@ def queryset(self, request, queryset):
2939
return queryset
3040

3141

42+
@transaction.atomic
43+
def merge_keywords_into_target(target, sources):
44+
"""Reassign every reference from ``sources`` onto ``target``, then delete the
45+
sources. Returns the number of source keywords removed.
46+
47+
For each source keyword and each model that references keywords, every
48+
tagged object gains the target via ``obj.keywords.add(target)`` — idempotent,
49+
so an object already tagged with the target ends up with a single row, not a
50+
duplicate. Deleting the source then drops its own M2M rows, so no explicit
51+
``remove()`` is needed. ``target`` is skipped if present in ``sources``.
52+
53+
Runs in a transaction: a failure mid-merge rolls back rather than leaving the
54+
taxonomy half-merged.
55+
"""
56+
removed = 0
57+
for source in sources:
58+
if source.pk == target.pk:
59+
continue
60+
for accessor in KEYWORD_REVERSE_ACCESSORS:
61+
for obj in getattr(source, accessor).all():
62+
obj.keywords.add(target)
63+
source.delete()
64+
removed += 1
65+
return removed
66+
67+
3268
@admin.register(Keyword, site=ml_admin_site)
3369
class KeywordAdmin(admin.ModelAdmin):
3470
list_display = ['keyword', 'project_count', 'publication_count', 'total_usage']
@@ -38,6 +74,69 @@ class KeywordAdmin(admin.ModelAdmin):
3874
search_fields = ['keyword']
3975
ordering = ['keyword']
4076
list_filter = (KeywordUsageFilter,)
77+
actions = ['merge_keywords']
78+
79+
@admin.action(description='Merge selected keywords (dedup taxonomy)')
80+
def merge_keywords(self, request, queryset):
81+
"""Merge two or more keywords into one chosen target (#1352).
82+
83+
Two-step Django action: the first click shows an intermediate page to
84+
pick which selected keyword to keep as the target; confirming reassigns
85+
every reference onto it (across all six keyword-holding models) and
86+
deletes the others. Destructive, so it always routes through the
87+
confirmation page — it never merges on the first click.
88+
"""
89+
selected = list(queryset.order_by('keyword'))
90+
if len(selected) < 2:
91+
self.message_user(
92+
request,
93+
"Select two or more keywords to merge.",
94+
level=messages.WARNING,
95+
)
96+
return None
97+
98+
if request.POST.get('confirm_merge'):
99+
target = next((k for k in selected
100+
if str(k.pk) == request.POST.get('target')), None)
101+
if target is None:
102+
self.message_user(
103+
request,
104+
"Pick which keyword to keep as the merge target.",
105+
level=messages.WARNING,
106+
)
107+
else:
108+
sources = [k for k in selected if k.pk != target.pk]
109+
source_names = ', '.join(f'"{k.keyword}"' for k in sources)
110+
removed = merge_keywords_into_target(target, sources)
111+
self.message_user(
112+
request,
113+
f'Merged {removed} keyword(s) ({source_names}) into '
114+
f'"{target.keyword}".',
115+
level=messages.SUCCESS,
116+
)
117+
return None # back to the changelist
118+
119+
# First click (or a target wasn't chosen): show the confirmation page,
120+
# annotating each candidate with its total usage to inform the choice.
121+
candidates = [
122+
{'keyword': k, 'total_uses': sum(
123+
getattr(k, accessor).count()
124+
for accessor in KEYWORD_REVERSE_ACCESSORS)}
125+
for k in selected
126+
]
127+
context = {
128+
**self.admin_site.each_context(request),
129+
'title': 'Merge keywords',
130+
'candidates': candidates,
131+
'selected_ids': [str(k.pk) for k in selected],
132+
'action_checkbox_name': ACTION_CHECKBOX_NAME,
133+
'opts': self.model._meta,
134+
}
135+
return render(
136+
request,
137+
'admin/website/keyword/merge_keywords.html',
138+
context,
139+
)
41140

42141
def change_view(self, request, object_id, form_url='', extra_context=None):
43142
"""Add projects and publications to the context. We then use this extra data in

website/models/keyword.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,26 @@
1+
import re
2+
13
from django.db import models
24

35
class Keyword(models.Model):
46
keyword = models.CharField(max_length=255)
57

8+
def save(self, *args, **kwargs):
9+
"""Normalize whitespace before saving so casual variants can't create
10+
near-duplicate tags (#1352). Trims the ends and collapses internal runs
11+
of whitespace to a single space, so "Speech ", " Speech", and
12+
"Speech recognition" can't coexist with their clean forms.
13+
14+
This is the partial, no-migration ward (layer 1): it catches every
15+
creation path, including the inline "add keyword" widget on Publication/
16+
Project forms. Case-insensitive uniqueness (e.g. blocking "Speech" vs
17+
"speech") is a separate DB constraint deferred until existing dupes are
18+
merged — casing is intentionally preserved here (VR, HCI, iOS).
19+
"""
20+
if self.keyword:
21+
self.keyword = re.sub(r'\s+', ' ', self.keyword).strip()
22+
super().save(*args, **kwargs)
23+
624
def get_project_count(self):
725
"""Returns the number of projects that use keyword"""
826
return self.project_set.count()

website/templates/admin/data_health/detail.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,16 @@
2323
<thead>
2424
<tr>
2525
{% for col in columns %}<th scope="col">{{ col }}</th>{% endfor %}
26+
{% if has_links %}<th scope="col">{% translate 'Action' %}</th>{% endif %}
2627
</tr>
2728
</thead>
2829
<tbody>
2930
{% for row in rows %}
3031
<tr class="{% cycle 'row1' 'row2' %}">
31-
{% for cell in row %}<td>{{ cell }}</td>{% endfor %}
32+
{% for cell in row.cells %}<td>{{ cell }}</td>{% endfor %}
33+
{% if has_links %}
34+
<td>{% if row.link %}<a class="button" href="{{ row.link.1 }}">{{ row.link.0 }}</a>{% endif %}</td>
35+
{% endif %}
3236
</tr>
3337
{% endfor %}
3438
</tbody>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{% extends "admin/base_site.html" %}
2+
{% load i18n %}
3+
4+
{% block breadcrumbs %}
5+
<div class="breadcrumbs">
6+
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
7+
&rsaquo; <a href="{% url 'admin:website_keyword_changelist' %}">{% translate 'Keywords' %}</a>
8+
&rsaquo; {% translate 'Merge keywords' %}
9+
</div>
10+
{% endblock %}
11+
12+
{% block content %}
13+
<p>
14+
{% blocktranslate trimmed %}
15+
Choose which keyword to <strong>keep</strong>. Every reference to the other
16+
selected keywords (across publications, talks, posters, grants, projects,
17+
and project umbrellas) will be reassigned to it, and the others will be
18+
<strong>permanently deleted</strong>. This cannot be undone.
19+
{% endblocktranslate %}
20+
</p>
21+
22+
<form method="post">
23+
{% csrf_token %}
24+
<input type="hidden" name="action" value="merge_keywords">
25+
<input type="hidden" name="confirm_merge" value="yes">
26+
{% for id in selected_ids %}
27+
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ id }}">
28+
{% endfor %}
29+
30+
<fieldset class="module aligned">
31+
<table id="result_list">
32+
<thead>
33+
<tr>
34+
<th scope="col">{% translate 'Keep as target' %}</th>
35+
<th scope="col">{% translate 'Keyword' %}</th>
36+
<th scope="col">{% translate 'Total uses' %}</th>
37+
</tr>
38+
</thead>
39+
<tbody>
40+
{% for candidate in candidates %}
41+
<tr class="{% cycle 'row1' 'row2' %}">
42+
<td>
43+
<input type="radio" name="target" id="target_{{ candidate.keyword.pk }}"
44+
value="{{ candidate.keyword.pk }}"{% if forloop.first %} checked{% endif %}>
45+
</td>
46+
<td><label for="target_{{ candidate.keyword.pk }}">{{ candidate.keyword.keyword }}</label></td>
47+
<td>{{ candidate.total_uses }}</td>
48+
</tr>
49+
{% endfor %}
50+
</tbody>
51+
</table>
52+
</fieldset>
53+
54+
<div class="submit-row">
55+
<input type="submit" class="default" value="{% translate 'Merge keywords' %}">
56+
<a href="{% url 'admin:website_keyword_changelist' %}" class="button cancel-link">{% translate 'Cancel' %}</a>
57+
</div>
58+
</form>
59+
{% endblock %}

0 commit comments

Comments
 (0)