diff --git a/sbas/templates/sbas/merge_category.html b/sbas/templates/sbas/merge_category.html
new file mode 100644
index 00000000..c5608f0e
--- /dev/null
+++ b/sbas/templates/sbas/merge_category.html
@@ -0,0 +1,48 @@
+{% extends 'sbas/base.html' %}
+
+{% block content %}
+
Merge Category
+
+ {% if error %}
+ {{ error }}
+ {% endif %}
+
+ {% if success %}
+ {{ success }}
+ {% endif %}
+
+ This admin tool will move all questions assigned to the Source category into the Target category. Optionally you may delete the Source category after the move.
+
+
+
+{% endblock %}
diff --git a/sbas/urls.py b/sbas/urls.py
index 1b32f189..48b8b6e7 100644
--- a/sbas/urls.py
+++ b/sbas/urls.py
@@ -110,6 +110,7 @@ urlpatterns = [
views.generate_exam,
name="generate_exam",
),
+ path("category/merge/", views.merge_category, name="merge_category"),
#path(
# "exam//scores///",
# views.exam_scores_cid_user,
diff --git a/sbas/views.py b/sbas/views.py
index 0d12b3ec..5bbf43e4 100644
--- a/sbas/views.py
+++ b/sbas/views.py
@@ -1012,6 +1012,59 @@ def import_llm_questions(request):
return JsonResponse({"ok": True, "report": report})
+@login_required
+@user_passes_test(lambda u: u.is_superuser)
+@require_http_methods(["GET", "POST"])
+def merge_category(request):
+ """Admin view: merge one Category into another.
+
+ GET: render form to choose source and target categories.
+ POST: expects 'source' and 'target' (category PKs) and optional 'delete_source' checkbox.
+ The view will reassign all Question.category==source -> target and optionally delete the source Category.
+ """
+ categories = Category.objects.all().order_by('category')
+
+ if request.method == 'GET':
+ return render(request, 'sbas/merge_category.html', {'categories': categories})
+
+ # POST
+ source_id = request.POST.get('source')
+ target_id = request.POST.get('target')
+ delete_source = request.POST.get('delete_source') in ('on', 'true', '1', 'True')
+
+ if not source_id or not target_id:
+ return render(request, 'sbas/merge_category.html', {'categories': categories, 'error': 'Both source and target categories must be selected.'})
+
+ try:
+ source = Category.objects.get(pk=int(source_id))
+ target = Category.objects.get(pk=int(target_id))
+ except Category.DoesNotExist:
+ return render(request, 'sbas/merge_category.html', {'categories': categories, 'error': 'Invalid category selected.'})
+
+ if source.pk == target.pk:
+ return render(request, 'sbas/merge_category.html', {'categories': categories, 'error': 'Source and target must be different.'})
+
+ # perform update inside a transaction
+ from django.db import transaction
+
+ with transaction.atomic():
+ qs = Question.objects.filter(category=source)
+ count = qs.count()
+ qs.update(category=target)
+
+ deleted = False
+ if delete_source:
+ # safe to delete now (no FK references from Question)
+ source.delete()
+ deleted = True
+
+ msg = f"Moved {count} question{'s' if count != 1 else ''} from '{source}' to '{target}'."
+ if deleted:
+ msg += f" Deleted source category '{source}'."
+
+ return render(request, 'sbas/merge_category.html', {'categories': Category.objects.all().order_by('category'), 'success': msg})
+
+
@login_required
@user_passes_test(lambda u: u.is_superuser)
@require_http_methods(["POST"])