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 +