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
25from website .models import (Keyword , Publication , Talk , Poster , Grant ,
36 Project , ProjectUmbrella )
47from website .admin .utils import related_count_subquery
1013# Artifact, so it has no keywords.)
1114KEYWORD_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
1424class 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 )
3369class 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
0 commit comments