From 06fe73e2a312f4678dd7392421abec9f2e1c55b9 Mon Sep 17 00:00:00 2001
From: Ross
Date: Wed, 25 Mar 2026 21:12:30 +0000
Subject: [PATCH] Add migration actions for Rapid questions to Shorts in admin
and templates
---
rapids/admin.py | 46 +++++++++++++
rapids/templates/rapids/exam_overview.html | 5 ++
.../rapids/question_link_header.html | 4 ++
rapids/urls.py | 2 +
rapids/views.py | 46 +++++++++++++
shorts/models.py | 65 +++++++++++++++++++
6 files changed, 168 insertions(+)
diff --git a/rapids/admin.py b/rapids/admin.py
index 6fa9b276..747bfe32 100755
--- a/rapids/admin.py
+++ b/rapids/admin.py
@@ -5,6 +5,7 @@ from django.forms import ModelForm
from reversion.admin import VersionAdmin
from django.forms.widgets import RadioSelect
+from django.contrib import messages
# Register your models here.
admin.site.register(Examination)
@@ -64,9 +65,54 @@ class RapidAdmin(VersionAdmin):
RapidAnswersInline,
ExamInline,
]
+ actions = ["migrate_to_shorts"]
+
+ def migrate_to_shorts(self, request, queryset):
+ """Admin action to migrate selected Rapid questions to shorts.Question"""
+ from shorts.models import Question as ShortQuestion
+
+ created = []
+ for rapid in queryset:
+ # allow admin or any author to perform migration
+ if request.user.is_superuser or rapid.author.filter(id=request.user.id).exists():
+ try:
+ q = ShortQuestion.from_rapid(rapid)
+ created.append(str(q.id))
+ except Exception as e:
+ self.message_user(request, f"Failed to migrate Rapid {rapid.id}: {e}", level=messages.ERROR)
+ else:
+ self.message_user(request, f"No permission to migrate Rapid {rapid.id}", level=messages.ERROR)
+
+ if created:
+ self.message_user(request, f"Created shorts questions: {', '.join(created)}", level=messages.SUCCESS)
+
+ migrate_to_shorts.short_description = "Migrate selected rapids to shorts"
class ExamAdmin(VersionAdmin, admin.ModelAdmin):
save_as = True
+ actions = ["migrate_exam_questions"]
+
+ def migrate_exam_questions(self, request, queryset):
+ """Admin action to migrate all Rapid questions linked to selected Exam(s)"""
+ from shorts.models import Question as ShortQuestion
+
+ created = []
+ for exam in queryset:
+ for rapid in exam.exam_questions.all():
+ # only allow if admin or author of rapid
+ if request.user.is_superuser or rapid.author.filter(id=request.user.id).exists():
+ try:
+ q = ShortQuestion.from_rapid(rapid)
+ created.append(str(q.id))
+ except Exception as e:
+ self.message_user(request, f"Failed to migrate Rapid {rapid.id}: {e}", level=messages.ERROR)
+ else:
+ self.message_user(request, f"No permission to migrate Rapid {rapid.id}", level=messages.ERROR)
+
+ if created:
+ self.message_user(request, f"Created shorts questions: {', '.join(created)}", level=messages.SUCCESS)
+
+ migrate_exam_questions.short_description = "Migrate all rapids in selected exam(s) to shorts"
admin.site.register(Rapid, RapidAdmin)
admin.site.register(Exam, ExamAdmin)
diff --git a/rapids/templates/rapids/exam_overview.html b/rapids/templates/rapids/exam_overview.html
index 06649893..3522d92c 100644
--- a/rapids/templates/rapids/exam_overview.html
+++ b/rapids/templates/rapids/exam_overview.html
@@ -14,6 +14,11 @@
{% endif %}
+ {# Migrate all rapids in this exam to shorts (exam authors + superusers) #}
+ {% if request.user.is_superuser or request.user in exam.author.all %}
+
+ {% endif %}
+
{% for question in questions.all %}
diff --git a/rapids/templates/rapids/question_link_header.html b/rapids/templates/rapids/question_link_header.html
index 6187804a..b3d6c8f8 100644
--- a/rapids/templates/rapids/question_link_header.html
+++ b/rapids/templates/rapids/question_link_header.html
@@ -10,6 +10,10 @@
Admin Edit
User answers
{% endif %}
+ {% comment %} Migrate to shorts (authors / rapid_checkers / superusers) {% endcomment %}
+ {% if request.user.is_superuser or request.user in question.author.all or request.user.groups.filter(name='rapid_checker').exists %}
+ Migrate to Shorts
+ {% endif %}
{% if exam %}
diff --git a/rapids/urls.py b/rapids/urls.py
index 6617371f..70a00c5d 100755
--- a/rapids/urls.py
+++ b/rapids/urls.py
@@ -30,6 +30,7 @@ urlpatterns = [
name="question_save_annotation",
),
path("question//split", views.rapid_split, name="rapid_split"),
+ path("question//migrate", views.question_migrate_to_shorts, name="question_migrate_to_shorts"),
path("question//clone", views.RapidClone.as_view(), name="rapid_clone"),
# path("verified/", views.verified, name="verified"),
# path("all_questions/", views.all_questions, name="all_questions"),
@@ -62,6 +63,7 @@ urlpatterns = [
path("exam/create", views.ExamCreate.as_view(), name="exam_create"),
path("exam//clone", views.ExamClone.as_view(), name="exam_clone"),
path("exam//update", views.ExamUpdate.as_view(), name="exam_update"),
+ path("exam//migrate_all", views.exam_migrate_rapids_to_shorts, name="exam_migrate_rapids_to_shorts"),
path("exam//delete", views.ExamDelete.as_view(), name="exam_delete"),
path("create/", views.RapidCreate.as_view(), name="question_create"),
path("create/exam/", views.RapidCreate.as_view(), name="question_create_exam"),
diff --git a/rapids/views.py b/rapids/views.py
index 4bd4408c..ae129250 100755
--- a/rapids/views.py
+++ b/rapids/views.py
@@ -821,6 +821,52 @@ def question_save_annotation(request, pk):
return JsonResponse(data, status=400)
+@user_is_author_or_rapid_checker
+def question_migrate_to_shorts(request, pk):
+ """Migrate a single Rapid question to a shorts.Question.
+
+ Accessible to question authors and members of the rapid_checker group (or superusers).
+ """
+ rapid = get_object_or_404(Rapid, pk=pk)
+
+ from shorts.models import Question as ShortQuestion
+
+ try:
+ q = ShortQuestion.from_rapid(rapid)
+ except Exception as e:
+ return HttpResponse(f"Migration failed: {e}", status=500)
+
+ return redirect(q.get_absolute_url())
+
+
+@login_required
+def exam_migrate_rapids_to_shorts(request, pk):
+ """Migrate all rapids associated with an Exam to shorts.Question.
+
+ Accessible to exam authors and superusers.
+ """
+ exam = get_object_or_404(Exam, pk=pk)
+
+ # check permission: exam author or superuser
+ if not (request.user.is_superuser or exam.author.filter(id=request.user.id).exists()):
+ raise PermissionDenied()
+
+ from shorts.models import Question as ShortQuestion
+
+ for rapid in exam.exam_questions.all():
+ try:
+ ShortQuestion.from_rapid(rapid)
+ except Exception:
+ # continue on error
+ continue
+
+ # Redirect to the exam overview for shorts if available, otherwise back to rapids exam
+ try:
+ return redirect("shorts:exam_overview", pk=exam.pk)
+ except Exception:
+ return redirect("rapids:exam_overview", pk=exam.pk)
+
+
class ExamClone(ExamCloneMixin, ExamCreate):
"""Clone exam view"""
diff --git a/shorts/models.py b/shorts/models.py
index 88aa6d4b..77d7ac23 100644
--- a/shorts/models.py
+++ b/shorts/models.py
@@ -286,6 +286,71 @@ class Question(QuestionBase):
return json
+ @classmethod
+ def from_rapid(cls, rapid):
+ """Create a `shorts.Question` from a `rapids.Rapid` instance.
+
+ Fields mapped:
+ - `history` -> `history`
+ - `examination` m2m copied
+ - `author` m2m copied
+ - images copied to `QuestionImage`
+ - `answers` (from `rapids.Answer`) -> `SampleAnswer` with simple score mapping
+
+ All other fields are concatenated into `marking_guidance`.
+ """
+
+ from rapids.models import Answer as RapidAnswer
+
+ guidance_parts = []
+ guidance_parts.append(f"Migrated from rapid id: {rapid.id}")
+ if rapid.laterality:
+ guidance_parts.append(f"Laterality: {rapid.laterality}")
+ abnormalities = ", ".join([a.name for a in rapid.abnormality.all()])
+ if abnormalities:
+ guidance_parts.append(f"Abnormalities: {abnormalities}")
+ regions = ", ".join([r.name for r in rapid.region.all()])
+ if regions:
+ guidance_parts.append(f"Regions: {regions}")
+ if rapid.question:
+ guidance_parts.append(f"Original question text: {rapid.question}")
+
+ marking_guidance = "\n".join(guidance_parts)
+
+ # Create the short question
+ q = cls.objects.create(
+ history=rapid.history,
+ marking_guidance=marking_guidance,
+ )
+
+ # copy m2m fields
+ try:
+ q.examination.set(rapid.examination.all())
+ except Exception:
+ pass
+
+ try:
+ q.author.set(rapid.author.all())
+ except Exception:
+ pass
+
+ # copy images
+ for img in rapid.images.all():
+ new_img = QuestionImage(
+ question=q,
+ image=img.image,
+ feedback_image=img.feedback_image,
+ filename=img.filename,
+ description=img.description,
+ image_md5_hash=img.image_md5_hash,
+ is_dicom=img.is_dicom,
+ )
+ new_img.save()
+
+ # Do not migrate rapid answers into shorts.SampleAnswer per request.
+
+ return q
+