Add migration actions for Rapid questions to Shorts in admin and templates
This commit is contained in:
@@ -5,6 +5,7 @@ from django.forms import ModelForm
|
|||||||
|
|
||||||
from reversion.admin import VersionAdmin
|
from reversion.admin import VersionAdmin
|
||||||
from django.forms.widgets import RadioSelect
|
from django.forms.widgets import RadioSelect
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
admin.site.register(Examination)
|
admin.site.register(Examination)
|
||||||
@@ -64,9 +65,54 @@ class RapidAdmin(VersionAdmin):
|
|||||||
RapidAnswersInline,
|
RapidAnswersInline,
|
||||||
ExamInline,
|
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):
|
class ExamAdmin(VersionAdmin, admin.ModelAdmin):
|
||||||
save_as = True
|
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(Rapid, RapidAdmin)
|
||||||
admin.site.register(Exam, ExamAdmin)
|
admin.site.register(Exam, ExamAdmin)
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
<a href="{% url 'rapids:mark_review' exam_pk=exam.pk sk=0 %}"><button>Review exam</button></a></p>
|
<a href="{% url 'rapids:mark_review' exam_pk=exam.pk sk=0 %}"><button>Review exam</button></a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Migrate all rapids in this exam to shorts (exam authors + superusers) #}
|
||||||
|
{% if request.user.is_superuser or request.user in exam.author.all %}
|
||||||
|
<a href="{% url 'rapids:exam_migrate_rapids_to_shorts' pk=exam.pk %}" onclick="return confirm('Migrate all rapids in this exam to shorts? This will create new short questions.')"><button>Migrate all to Shorts</button></a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<ol id="full-question-list" class="sortable">
|
<ol id="full-question-list" class="sortable">
|
||||||
{% for question in questions.all %}
|
{% for question in questions.all %}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
<a href="{% url 'admin:rapids_rapid_change' question.id %}" title="Edit the Rapid using the admin interface">Admin Edit</a>
|
<a href="{% url 'admin:rapids_rapid_change' question.id %}" title="Edit the Rapid using the admin interface">Admin Edit</a>
|
||||||
<a href="{% url 'rapids:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
|
<a href="{% url 'rapids:question_user_answers' question.id %}" title="View user answers associated with this question">User answers</a>
|
||||||
{% endif %}
|
{% 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 %}
|
||||||
|
<a href="{% url 'rapids:question_migrate_to_shorts' pk=question.pk %}" onclick="return confirm('Migrate this Rapid to a Short question?')">Migrate to Shorts</a>
|
||||||
|
{% endif %}
|
||||||
{% if exam %}
|
{% if exam %}
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ urlpatterns = [
|
|||||||
name="question_save_annotation",
|
name="question_save_annotation",
|
||||||
),
|
),
|
||||||
path("question/<int:pk>/split", views.rapid_split, name="rapid_split"),
|
path("question/<int:pk>/split", views.rapid_split, name="rapid_split"),
|
||||||
|
path("question/<int:pk>/migrate", views.question_migrate_to_shorts, name="question_migrate_to_shorts"),
|
||||||
path("question/<int:pk>/clone", views.RapidClone.as_view(), name="rapid_clone"),
|
path("question/<int:pk>/clone", views.RapidClone.as_view(), name="rapid_clone"),
|
||||||
# path("verified/", views.verified, name="verified"),
|
# path("verified/", views.verified, name="verified"),
|
||||||
# path("all_questions/", views.all_questions, name="all_questions"),
|
# 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/create", views.ExamCreate.as_view(), name="exam_create"),
|
||||||
path("exam/<int:exam_id>/clone", views.ExamClone.as_view(), name="exam_clone"),
|
path("exam/<int:exam_id>/clone", views.ExamClone.as_view(), name="exam_clone"),
|
||||||
path("exam/<int:pk>/update", views.ExamUpdate.as_view(), name="exam_update"),
|
path("exam/<int:pk>/update", views.ExamUpdate.as_view(), name="exam_update"),
|
||||||
|
path("exam/<int:pk>/migrate_all", views.exam_migrate_rapids_to_shorts, name="exam_migrate_rapids_to_shorts"),
|
||||||
path("exam/<int:pk>/delete", views.ExamDelete.as_view(), name="exam_delete"),
|
path("exam/<int:pk>/delete", views.ExamDelete.as_view(), name="exam_delete"),
|
||||||
path("create/", views.RapidCreate.as_view(), name="question_create"),
|
path("create/", views.RapidCreate.as_view(), name="question_create"),
|
||||||
path("create/exam/<int:pk>", views.RapidCreate.as_view(), name="question_create_exam"),
|
path("create/exam/<int:pk>", views.RapidCreate.as_view(), name="question_create_exam"),
|
||||||
|
|||||||
@@ -821,6 +821,52 @@ def question_save_annotation(request, pk):
|
|||||||
return JsonResponse(data, status=400)
|
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):
|
class ExamClone(ExamCloneMixin, ExamCreate):
|
||||||
"""Clone exam view"""
|
"""Clone exam view"""
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,71 @@ class Question(QuestionBase):
|
|||||||
|
|
||||||
return json
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user