From 7538557af9c4fa6fb43f055dc4e75a52947c7e11 Mon Sep 17 00:00:00 2001 From: Oliver Stolpe Date: Mon, 24 Jan 2022 22:18:34 +0100 Subject: [PATCH] Added feature to select multiple rows in results to create same annotation (#259) (#280) --- HISTORY.rst | 2 + svs/models.py | 3 + svs/templates/svs/filter_result/header.html | 3 +- svs/templates/svs/filter_result/row_sv.html | 11 +- svs/templates/svs/filter_result/table.html | 48 +- svs/templates/svs/scripts.html | 2 + svs/urls.py | 5 + svs/views.py | 159 ++++++- varfish/static/js/flags_comments.js | 143 +++++- varfish/static/js/state_machine.js | 3 +- variants/models.py | 3 + .../_multi_variant_flag_form_tpl.html | 190 ++++++++ .../variants/filter_result/header.html | 4 +- .../templates/variants/filter_result/row.html | 6 +- .../variants/filter_result/table.html | 31 +- variants/templates/variants/scripts.html | 2 + variants/tests/factories.py | 19 +- variants/tests/test_ui.py | 239 +++++++++- variants/tests/test_views.py | 434 ++++++++++++++++++ variants/urls.py | 5 + variants/views.py | 139 ++++++ 21 files changed, 1423 insertions(+), 28 deletions(-) create mode 100644 variants/templates/variants/_multi_variant_flag_form_tpl.html diff --git a/HISTORY.rst b/HISTORY.rst index 74cf3ab8e..fc28ae01d 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -32,6 +32,7 @@ End-User Summary For this, GRCh38 background data must be imported. Kiosk mode does not support GRCh38 yet. **This is a breaking change, new data and CLI must be used!** +- Added feature to select multiple rows in results to create same annotation (#259) Full Change List ================ @@ -65,6 +66,7 @@ Full Change List - Setting ``VARFISH_CADD_SUBMISSION_RELEASE`` is called ``VARFISH_CADD_SUBMISSION_VERSION`` now (**breaking change**). - ``import_info.tsv`` expected as in data release from ``20210728`` as built from varfish-db-downloader ``1b03e97`` or later. - Extending columns of ``Hgnc`` to upstream update. +- Added feature to select multiple rows in results to create same annotation (#259) ------- v0.23.9 diff --git a/svs/models.py b/svs/models.py index b029a99a2..e088c59e3 100644 --- a/svs/models.py +++ b/svs/models.py @@ -430,6 +430,9 @@ def no_flags_set(self): self.flag_candidate, self.flag_final_causative, self.flag_for_validation, + self.flag_no_disease_association, + self.flag_segregates, + self.flag_doesnt_segregate, self.flag_molecular != "empty", self.flag_visual != "empty", self.flag_validation != "empty", diff --git a/svs/templates/svs/filter_result/header.html b/svs/templates/svs/filter_result/header.html index 4d5ecfb59..c815c4166 100644 --- a/svs/templates/svs/filter_result/header.html +++ b/svs/templates/svs/filter_result/header.html @@ -2,8 +2,9 @@ {# Variant filter table header #} - {# rank #} {# fold-out #} + {# rank #} + {# checkbox #} {# bookmark #} SV type / caller {# sv_type + caller #} diff --git a/svs/templates/svs/filter_result/row_sv.html b/svs/templates/svs/filter_result/row_sv.html index 20f63a287..39202afad 100644 --- a/svs/templates/svs/filter_result/row_sv.html +++ b/svs/templates/svs/filter_result/row_sv.html @@ -5,7 +5,7 @@ {% load regmaps_tags %} {% load dict %} - + {# fold-out #} #{{ forloop.counter }} - + {# checkbox #} + + + {# bookmark #} {% if not training_mode %} - - + + {% endif %} diff --git a/svs/templates/svs/filter_result/table.html b/svs/templates/svs/filter_result/table.html index 9b2e42196..b4e2bbe2f 100644 --- a/svs/templates/svs/filter_result/table.html +++ b/svs/templates/svs/filter_result/table.html @@ -22,15 +22,25 @@

{{ rows_by_sv|length|intcomma }} SV calls {% if object %}(case has a total of {{ object.num_svs|intcomma }}{% endif %} SV calls) -
- Using - {% if database == "refseq" %} - RefSeq - {% else %} - ENSEMBL - {% endif %} - transcripts. +
+ Using + {% if database == "refseq" %} + RefSeq + {% else %} + ENSEMBL + {% endif %} + transcripts. +
+
@@ -54,5 +64,27 @@

Query completed in {{ elapsed_seconds }} sec.

+ + {# Multi-var bookmark and comment modal #} + {% endif %} diff --git a/svs/templates/svs/scripts.html b/svs/templates/svs/scripts.html index 785368986..9e8c060eb 100644 --- a/svs/templates/svs/scripts.html +++ b/svs/templates/svs/scripts.html @@ -30,9 +30,11 @@ let structural_or_small = "structural"; let variant_flags_url = "{% url 'svs:sv-flags-api' project=project.sodar_uuid case="--abcef--" sv="--bbccee--" %}"; let variant_comment_url = "{% url 'svs:sv-comment-api' project=project.sodar_uuid case="--abcef--" sv="--bbccee--" %}"; + let multi_variant_flags_comment_url = "{% url 'svs:multi-sv-flags-comment-api' project=project.sodar_uuid %}"; {% include "variants/_variant_flag_form_tpl.html" %} +{% include "variants/_multi_variant_flag_form_tpl.html" %} diff --git a/svs/urls.py b/svs/urls.py index f647e16c1..85e87f3ae 100644 --- a/svs/urls.py +++ b/svs/urls.py @@ -28,6 +28,11 @@ view=views.StructuralVariantCommentApiView.as_view(), name="sv-comment-api", ), + url( + regex=r"^(?P[0-9a-f-]+)/multi-sv-flags-comment", + view=views.MultiStructuralVariantFlagsAndCommentApiView.as_view(), + name="multi-sv-flags-comment-api", + ), # Views for variants import job. url( regex=r"^(?P[0-9a-f-]+)/import/(?P[0-9a-f-]+)/$", diff --git a/svs/views.py b/svs/views.py index a1c099b06..aaec86678 100644 --- a/svs/views.py +++ b/svs/views.py @@ -8,7 +8,7 @@ from projectroles.views import LoginRequiredMixin from django.db import transaction from django.forms import model_to_dict -from django.http import HttpResponse, Http404 +from django.http import HttpResponse, Http404, JsonResponse from django.shortcuts import render, redirect, get_object_or_404, reverse from django.utils import timezone from django.views import View @@ -341,10 +341,10 @@ def post(self, *_args, **kwargs): if timeline: tl_event = timeline.add_event( project=self.get_project(self.request, self.kwargs), - app_name="variants", + app_name="svs", user=self.request.user, event_name="comment_add", - description="add comment for variant %s in case {case}: {text}" + description="add comment for structural variant %s in case {case}: {text}" % comment.get_variant_description(), status_type="OK", ) @@ -353,6 +353,159 @@ def post(self, *_args, **kwargs): return HttpResponse(json.dumps({"result": "OK"}), content_type="application/json") +class MultiStructuralVariantFlagsAndCommentApiView( + LoginRequiredMixin, LoggedInPermissionMixin, ProjectPermissionMixin, ProjectContextMixin, View, +): + """A view that returns JSON for the ``SmallVariantFlags`` for a variant of a case and allows updates.""" + + # TODO: create new permission + permission_required = "variants.view_data" + + def get(self, *_args, **_kwargs): + get_data = dict(self.request.GET) + variant_list = json.loads(get_data.get("variant_list")[0]) + flags_keys = [ + "flag_bookmarked", + "flag_candidate", + "flag_final_causative", + "flag_for_validation", + "flag_no_disease_association", + "flag_segregates", + "flag_doesnt_segregate", + "flag_visual", + "flag_molecular", + "flag_validation", + "flag_phenotype_match", + "flag_summary", + ] + flags = {i: None for i in flags_keys} + flags_interfering = set() + + for variant in reversed(variant_list): + case = get_object_or_404(Case, sodar_uuid=variant.get("case")) + + try: + flag_data = model_to_dict(self._get_flags_for_variant(variant.get("sv_uuid"), case)) + + for flag in flags_keys: + if flags[flag] is None: + flags[flag] = flag_data[flag] + + if not flags[flag] == flag_data[flag]: + flags_interfering.add(flag) + + flags[flag] = flag_data[flag] + + except StructuralVariantFlags.DoesNotExist: + continue + + results = { + "flags": flags, + "flags_interfering": sorted(flags_interfering), + "variant_list": variant_list, + } + + return JsonResponse(results, UUIDEncoder) + + def post(self, *_args, **_kwargs): + timeline = get_backend_api("timeline_backend") + post_data = dict(self.request.POST) + variant_list = post_data.pop("variant_list")[0] + post_data.pop("csrfmiddlewaretoken") + post_data_clean = {k: v[0] for k, v in post_data.items()} + text = post_data_clean.pop("text") + + for variant in json.loads(variant_list): + case = get_object_or_404(Case, sodar_uuid=variant.get("case")) + sv = StructuralVariant.objects.get(sv_uuid=variant.get("sv_uuid")) + + try: + flags = self._get_flags_for_variant(variant.get("sv_uuid"), case) + + except StructuralVariantFlags.DoesNotExist: + flags = StructuralVariantFlags( + case=case, + bin=sv.bin, + release=sv.release, + chromosome=sv.chromosome, + start=sv.start, + end=sv.end, + sv_type=sv.sv_type, + sv_sub_type=sv.sv_sub_type, + ) + flags.save() + + form = StructuralVariantFlagsForm({**variant, **post_data_clean}, instance=flags) + + try: + flags = form.save() + + except ValueError as e: + raise Exception(str(form.errors)) from e + + if timeline: + tl_event = timeline.add_event( + project=self.get_project(self.request, self.kwargs), + app_name="svs", + user=self.request.user, + event_name="flags_set", + description="set flags for structural variant %s in case {case}: {extra-flag_values}" + % sv, + status_type="OK", + extra_data={"flag_values": flags.human_readable()}, + ) + tl_event.add_object(obj=case, label="case", name=case.name) + + if flags.no_flags_set(): + flags.delete() + + if text: + comment = StructuralVariantComment( + case=case, + user=self.request.user, + bin=sv.bin, + release=sv.release, + chromosome=sv.chromosome, + start=sv.start, + end=sv.end, + sv_type=sv.sv_type, + sv_sub_type=sv.sv_sub_type, + sodar_uuid=uuid.uuid4(), + ) + form = StructuralVariantCommentForm({**variant, "text": text}, instance=comment) + + try: + comment = form.save() + + except ValueError as e: + raise Exception(str(form.errors)) from e + + if timeline: + tl_event = timeline.add_event( + project=self.get_project(self.request, self.kwargs), + app_name="svs", + user=self.request.user, + event_name="comment_add", + description="add comment for structural variant %s in case {case}: {text}" + % comment.get_variant_description(), + status_type="OK", + ) + tl_event.add_object(obj=case, label="case", name=case.name) + tl_event.add_object(obj=comment, label="text", name=comment.shortened_text()) + + return JsonResponse({"message": "OK", "flags": post_data_clean, "comment": text}) + + def _get_flags_for_variant(self, sv_uuid, case): + with contextlib.closing(best_matching_flags(get_engine(), case.id, sv_uuid)) as results: + result = results.first() + if not result: + raise StructuralVariantFlags.DoesNotExist() + else: + return StructuralVariantFlags.objects.get( + case_id=case.id, sodar_uuid=result.flags_uuid + ) + + class ImportStructuralVariantsJobDetailView( LoginRequiredMixin, LoggedInPermissionMixin, diff --git a/varfish/static/js/flags_comments.js b/varfish/static/js/flags_comments.js index 5f4a9f667..2507666e4 100644 --- a/varfish/static/js/flags_comments.js +++ b/varfish/static/js/flags_comments.js @@ -105,9 +105,11 @@ function clickVariantBookmark() { var variantRow = outerThis.closest(".variant-row"); variantRow.removeClass("variant-row-positive variant-row-uncertain variant-row-negative variant-row-empty variant-row-wip"); variantRow.addClass("variant-row-" + summarizeFlags(data)); - let dtrow = dt.row(variantRow); - if (structural_or_small == "small" && dtrow.child() && dtrow.child().length) { - loadVariantDetails(dtrow, cell); + if (structural_or_small === "small") { + let dtrow = dt.row(variantRow); + if (dtrow.child() && dtrow.child().length) { + loadVariantDetails(dtrow, cell); + } } }).fail(function(xhr) { // failed, notify user @@ -219,6 +221,118 @@ $('body').on('click', function (e) { }); }); +function clickMultiVariantBookmark() { + // Compile template. + var bookmarkModalTpl = $.templates("#multi-bookmark-flags-modal"); + var multiVars = $(".multivar-selector:checked"); + var variantList = []; + var rowIds = []; + multiVars.each(function(i, e) { + if (structural_or_small == "small") { + var dataVariant = $(e).val(); + var arrVariant = dataVariant.split("-"); + variantList.push({ + case: $(e).data("case"), + release: arrVariant[0], + chromosome: arrVariant[1], + start: arrVariant[2], + end: arrVariant[3], + bin: arrVariant[4], + reference: arrVariant[5], + alternative: arrVariant[6], + }) + rowIds.push("#" + $(e).data("case") + "-" + dataVariant) + } + else { // structural_or_small == "structural" + variantList.push({ + case: $(e).data("case"), + sv_uuid: $(e).val(), + }) + rowIds.push("#" + $(e).data("case") + "-" + $(e).val()); + } + }); + + var modal = $("#multiVarBookmarkCommentModal"); + + // Retrieve current small variant flags from server via AJAX. + $.ajax({ + url: multi_variant_flags_comment_url, + data: "variant_list=" + JSON.stringify(variantList), + dataType: "json" + }).done(function(data) { + var flags = { + flag_bookmarked: true, + flag_for_validation: false, + flag_candidate: false, + flag_final_causative: false, + flag_visual: "empty", + flag_molecular: "empty", + flag_validation: "empty", + flag_phenotype_match: "empty", + flag_summary: "empty", + } + $.each(data["flags"], function(k, v) { + if ($.inArray(k, data["flags_interfering"]) > -1) { + flags[k + "_warning"] = true + flags["warning_exists"] = true + } + if (v !== null) { + flags[k] = v + } + }) + flags["number_selected_variants"] = data["variant_list"].length + $("#multiVarBookmarkCommentModalContent").html($(bookmarkModalTpl.render(flags))); + $(modal).find(".save").click(function(event) { + event.preventDefault(); // we will handle everything + $(this).addClass("disabled"); + + // Save flags + var formData = $(this).closest(".modal-content").find("form").serialize(); + $.ajax({ + type: "POST", + url: multi_variant_flags_comment_url, + data: formData + "&variant_list=" + JSON.stringify(variantList) + "&csrfmiddlewaretoken=" + getCookie("csrftoken"), + dataType: "json", + }).done(function(data) { + if (data["message"] === "OK") { + $.each(rowIds, function(i, e) { + var iconBookmark = $(e).find(".variant-bookmark") + var iconComment = $(e).find(".variant-comment") + var d = data["flags"] + + if (d["flag_bookmarked"] || d["flag_for_validation"] || d["flag_candidate"] || d["flag_final_causative"]) { + iconBookmark.attr("src", "/icons/fa-solid/bookmark.svg"); + } else { + iconBookmark.attr("src", "/icons/fa-regular/bookmark.svg"); + } + + if (data["comment"]) { + iconComment.attr("src", "/icons/fa-solid/comment.svg"); + } + + $(e).removeClass("variant-row-positive variant-row-uncertain variant-row-negative variant-row-empty variant-row-wip"); + $(e).addClass("variant-row-" + summarizeFlags(d)); + if (structural_or_small === "small") { + let dtrow = dt.row($(e)); + if (dtrow.child() && dtrow.child().length) { + loadVariantDetails(dtrow, cell); + } + } + }); + } + }).fail(function(xhr) { + // failed, notify user + alert("Updating variant flags failed"); + }); + + $(modal).modal("hide"); + $(this).removeClass("disabled"); + }); + }).fail(function(xhr) { + alert("Retrieving variant flags failed"); + }); + // Setup the form elements so we can use AJAX for them. +} // -------------------------------------------------------------------------- // Variant ACMG Rating Popover / AJAX Form @@ -424,6 +538,27 @@ function clickVariantAcmgRating() { }); } + +function toggleMultiVarOptionsDropdown() { + var button = $("#multiVarButton") + var option1 = $("#multivar-bookmark-comment") + + if ($(".multivar-selector:checked").length > 1) { + option1.removeClass("disabled") + button.removeClass("btn-outline-secondary") + button.addClass("btn-secondary") + } + + else { + option1.addClass("disabled") + button.addClass("btn-outline-secondary") + button.removeClass("btn-secondary") + } +} + + $(document).on("click", ".variant-bookmark-comment-group", clickVariantBookmark); -//$(document).on("click", ".variant-comment", clickVariantBookmark); $(document).on("click", ".variant-acmg", clickVariantAcmgRating); +$(document).on("click", "#multivar-bookmark-comment", clickMultiVariantBookmark); +$(document).on("click", ".multivar-selector", toggleMultiVarOptionsDropdown); + diff --git a/varfish/static/js/state_machine.js b/varfish/static/js/state_machine.js index c772082a5..af3068391 100644 --- a/varfish/static/js/state_machine.js +++ b/varfish/static/js/state_machine.js @@ -113,6 +113,7 @@ function updateTableDisplay() { whitelist.iframe = ['src', 'style', 'width', 'height', 'frameborder', 'vspace', 'hspace'] // Alternative: skip sanitize function entirely //$('[data-toggle="popover"]').popover({sanitizeFn: function(content) { return content }}) + toggleMultiVarOptionsDropdown(); } function displayConnectionError() { @@ -413,7 +414,7 @@ function handleEventStateWaitJobResults(eventType, event) { "order": [[ 1, "asc" ]], 'aoColumnDefs': [ { - 'aTargets': [0,2,3,5,6,8,9,28,-1], /* column index */ + 'aTargets': [0,2,3,4,6,7,9,10,29,-1], /* column index */ 'bSortable': false, /* true or false */ }, ] diff --git a/variants/models.py b/variants/models.py index 7f772f137..c8e4e4956 100644 --- a/variants/models.py +++ b/variants/models.py @@ -1566,6 +1566,9 @@ def no_flags_set(self): self.flag_candidate, self.flag_final_causative, self.flag_for_validation, + self.flag_no_disease_association, + self.flag_segregates, + self.flag_doesnt_segregate, self.flag_molecular != "empty", self.flag_visual != "empty", self.flag_validation != "empty", diff --git a/variants/templates/variants/_multi_variant_flag_form_tpl.html b/variants/templates/variants/_multi_variant_flag_form_tpl.html new file mode 100644 index 000000000..7821fd88b --- /dev/null +++ b/variants/templates/variants/_multi_variant_flag_form_tpl.html @@ -0,0 +1,190 @@ +{# Bookmark for the variant bookmark popup #} +{% verbatim %} + +{% endverbatim %} diff --git a/variants/templates/variants/filter_result/header.html b/variants/templates/variants/filter_result/header.html index bdc702321..310ff0eae 100644 --- a/variants/templates/variants/filter_result/header.html +++ b/variants/templates/variants/filter_result/header.html @@ -4,6 +4,7 @@ {# fold-out #} {# rank #} + {# checkbox #} {# bookmark #} {# databases #} Coordinates @@ -56,7 +57,8 @@ {# fold-out #} - {# fold-out #} + {# rank #} + {# checkbox #} {# bookmark #} {# databases #} position diff --git a/variants/templates/variants/filter_result/row.html b/variants/templates/variants/filter_result/row.html index cba4de1a7..9ab16503a 100644 --- a/variants/templates/variants/filter_result/row.html +++ b/variants/templates/variants/filter_result/row.html @@ -11,7 +11,7 @@ {% ambiguous_frequency_warning entry exac_enabled thousand_genomes_enabled gnomad_exomes_enabled gnomad_genomes_enabled inhouse_enabled as warning %} {% with symbol=entry|get_symbol %} - + {# fold-out #} #{{ forloop.counter }} + {# checkbox #} + + + {# bookmark #} {% if not training_mode %} diff --git a/variants/templates/variants/filter_result/table.html b/variants/templates/variants/filter_result/table.html index cc5817617..98ea0ed7b 100644 --- a/variants/templates/variants/filter_result/table.html +++ b/variants/templates/variants/filter_result/table.html @@ -232,12 +232,22 @@

+
@@ -257,6 +267,25 @@

Query completed in {{ elapsed_seconds }} sec.

+ + {# Multi-var bookmark and comment modal #} + +

{% include "variants/_variant_flag_form_tpl.html" %} +{% include "variants/_multi_variant_flag_form_tpl.html" %} {% include "variants/_acmg_criteria_form_tpl.html" %} diff --git a/variants/tests/factories.py b/variants/tests/factories.py index 84df57d1c..298806826 100644 --- a/variants/tests/factories.py +++ b/variants/tests/factories.py @@ -238,23 +238,38 @@ class ChromosomalPositionFormDataFactoryBase: @attr.s(auto_attribs=True) -class SmallVariantFlagsFormDataFactory(ChromosomalPositionFormDataFactoryBase): +class FlagsFormDataFactoryBase: flag_bookmarked: bool = True flag_candidate: bool = False flag_final_causative: bool = False flag_for_validation: bool = False - flag_molecular: str = "empty" + flag_no_disease_association: bool = False + flag_segregates: bool = False + flag_doesnt_segregate: bool = False flag_visual: str = "empty" + flag_molecular: str = "empty" flag_validation: str = "empty" flag_phenotype_match: str = "empty" flag_summary: str = "empty" +@attr.s(auto_attribs=True) +class SmallVariantFlagsFormDataFactory( + FlagsFormDataFactoryBase, ChromosomalPositionFormDataFactoryBase +): + pass + + @attr.s(auto_attribs=True) class SmallVariantCommentFormDataFactory(ChromosomalPositionFormDataFactoryBase): text: str = "Comment X" +@attr.s(auto_attribs=True) +class MultiSmallVariantFlagsAndCommentFormDataFactory(FlagsFormDataFactoryBase): + text: str = "Comment X" + + @attr.s(auto_attribs=True) class AcmgCriteriaRatingFormDataFactory(ChromosomalPositionFormDataFactoryBase): pvs1: int = 0 diff --git a/variants/tests/test_ui.py b/variants/tests/test_ui.py index 841365abe..e582c6331 100644 --- a/variants/tests/test_ui.py +++ b/variants/tests/test_ui.py @@ -100,6 +100,64 @@ def __call__(self, driver): return False +class wait_for_element_endswith_value(object): + """https://stackoverflow.com/a/43813210/84349 + + Usage: + + self.wait.until(wait_for_the_attribute_value((By.ID, "xxx"), "aria-busy", "false")) + + """ + + def __init__(self, element, attribute, value): + self.element = element + self.attribute = attribute + self.value = value + + def __call__(self, driver): + try: + element_attribute = self.element.get_attribute(self.attribute) + return element_attribute.endswith(self.value) + except StaleElementReferenceException: + return False + + +class element_has_class_locator(object): + """An expectation for checking that an element has a particular css class. + locator - used to find the element + returns the WebElement once it has the particular css class + """ + + def __init__(self, locator, css_class): + self.locator = locator + self.css_class = css_class + + def __call__(self, driver): + element = driver.find_element(*self.locator) # Finding the referenced element + + if self.css_class in element.get_attribute("class"): + return element + else: + return False + + +class element_has_class(object): + """An expectation for checking that an element has a particular css class. + locator - used to find the element + returns the WebElement once it has the particular css class + """ + + def __init__(self, element, css_class): + self.element = element + self.css_class = css_class + + def __call__(self, driver): + if self.css_class in self.element.get_attribute("class"): + return self.element + else: + return False + + class LiveUserMixin: """Mixin for creating users to work with LiveServerTestCase""" @@ -292,8 +350,11 @@ class TestVariantsCaseFilterView(TestUIBase): def setUp(self): super().setUp() - self.case, variant_set, _ = CaseWithVariantSetFactory.get("small") - small_var = SmallVariantFactory(variant_set=variant_set) + self.case, self.variant_set, _ = CaseWithVariantSetFactory.get("small") + SmallVariantFactory(variant_set=self.variant_set) + + def _create_two_more_variants(self): + SmallVariantFactory.create_batch(2, variant_set=self.variant_set) def _disable_effect_groups(self): """Helper function to disable all effect checkboxes by activating and deactivating the 'all' checkbox.""" @@ -580,6 +641,180 @@ def test_variant_filter_case_bookmark(self): ) ) + @skipIf(SKIP_SELENIUM, SKIP_SELENIUM_MESSAGE) + def test_variant_filter_case_multi_bookmark_one_variant(self): + """Test if submitting the filter yields the expected results.""" + self._create_two_more_variants() + + # login + self.compile_url_and_login( + {"project": self.case.project.sodar_uuid, "case": self.case.sodar_uuid} + ) + self._disable_filters("case") + # hit submit button + self.selenium.find_element_by_id("submitFilter").click() + WebDriverWait(self.selenium, self.wait_time).until( + ec.presence_of_element_located((By.CLASS_NAME, "multivar-selector")) + ) + multivar_btn = WebDriverWait(self.selenium, self.wait_time).until( + ec.presence_of_element_located((By.ID, "multiVarButton")) + ) + multivar_bookmark_link = WebDriverWait(self.selenium, self.wait_time).until( + ec.presence_of_element_located((By.ID, "multivar-bookmark-comment")) + ) + WebDriverWait(self.selenium, self.wait_time).until( + element_has_class(multivar_btn, "btn-outline-secondary") + ) + + selectors = self.selenium.find_elements_by_class_name("multivar-selector") + + # check if buttons are disabled + self.assertIn("btn-outline-secondary", multivar_btn.get_attribute("class")) + self.assertIn("disabled", multivar_bookmark_link.get_attribute("class")) + + # select the first variant + selectors[0].click() + self.assertTrue(selectors[0].is_selected()) + + # buttons should still be disabled + self.assertIn("btn-outline-secondary", multivar_btn.get_attribute("class")) + self.assertIn("disabled", multivar_bookmark_link.get_attribute("class")) + + # select the second variant + selectors[1].click() + self.assertTrue(selectors[1].is_selected()) + self.assertFalse(selectors[2].is_selected()) + + # buttons should be active + self.assertIn("btn-secondary", multivar_btn.get_attribute("class")) + self.assertNotIn("disabled", multivar_bookmark_link.get_attribute("class")) + + # open form + multivar_btn.click() + multivar_bookmark_link.click() + + # check displayed form + info_box = WebDriverWait(self.selenium, self.wait_time).until( + ec.presence_of_element_located( + ( + By.XPATH, + "//div[@id='multiVarBookmarkCommentModalContent']/div[contains(@class, 'alert-info')]", + ) + ) + ) + self.assertEqual("2 variants selected.", info_box.text) + + # save bookmark + WebDriverWait(self.selenium, self.wait_time).until( + ec.presence_of_element_located((By.CLASS_NAME, "save")) + ).click() + + # check bookmark set for variant 1 + WebDriverWait(self.selenium, self.wait_time).until( + wait_for_element_endswith_value( + selectors[0].find_element_by_xpath( + "../following-sibling::td[contains(@class, 'bookmark')]/a/img[@class='variant-bookmark']" + ), + "src", + "/icons/fa-solid/bookmark.svg", + ) + ) + # check bookmark set for variant 2 + WebDriverWait(self.selenium, self.wait_time).until( + wait_for_element_endswith_value( + selectors[1].find_element_by_xpath( + "../following-sibling::td[contains(@class, 'bookmark')]/a/img[@class='variant-bookmark']" + ), + "src", + "/icons/fa-solid/bookmark.svg", + ) + ) + # check if bookmark NOT set for variant 3 + self.assertTrue( + selectors[2] + .find_element_by_xpath( + "../following-sibling::td[contains(@class, 'bookmark')]/a/img[@class='variant-bookmark']" + ) + .get_attribute("src") + .endswith("/icons/fa-regular/bookmark.svg") + ) + + # un-select variant 1 + selectors[0].click() + self.assertFalse(selectors[0].is_selected()) + self.assertTrue(selectors[1].is_selected()) + + # select variant 3 + selectors[2].click() + self.assertTrue(selectors[2].is_selected()) + + # open form again + multivar_btn.click() + multivar_bookmark_link.click() + + # select checkered flag + comment_check_flag = WebDriverWait(self.selenium, self.wait_time).until( + ec.element_to_be_clickable((By.ID, "comment-check-flag")) + ) + comment_check_flag.click() + + # select visual positive flag + comment_radio_visual_positive = WebDriverWait(self.selenium, self.wait_time).until( + ec.element_to_be_clickable((By.ID, "comment-radio-visual-positive")) + ) + comment_radio_visual_positive.click() + + # save form + WebDriverWait(self.selenium, self.wait_time).until( + ec.element_to_be_clickable((By.CLASS_NAME, "save")) + ).click() + + # select variant 1 (all three are now selected) + selectors[0].click() + self.assertTrue(selectors[0].is_selected()) + + # open form again + multivar_btn.click() + multivar_bookmark_link.click() + + # check displayed form -- warning expected + WebDriverWait(self.selenium, self.wait_time).until( + ec.presence_of_element_located( + ( + By.XPATH, + "//div[@id='multiVarBookmarkCommentModalContent']/div[contains(@class, 'alert-warning')]", + ) + ) + ) + info_box = WebDriverWait(self.selenium, self.wait_time).until( + ec.presence_of_element_located( + ( + By.XPATH, + "//div[@id='multiVarBookmarkCommentModalContent']/div[contains(@class, 'alert-info')]", + ) + ) + ) + self.assertEqual("3 variants selected.", info_box.text) + + # get highlighted flags + flags_radios = self.selenium.find_elements_by_xpath( + "//div[@id='multiVarBookmarkCommentModalContent']/form/div[contains(@class, 'alert-warning')]" + ) + flags_checkboxes = self.selenium.find_elements_by_xpath( + "//div[@id='multiVarBookmarkCommentModalContent']/form/div/div/div[contains(@class, 'alert-warning')]" + ) + + # check for correct number of highlighted flags + self.assertEqual(len(flags_radios), 1) + self.assertEqual(len(flags_checkboxes), 1) + + # check if correct flags where highlighted + self.assertEqual(flags_radios[0].text, "Visual") + self.assertEqual( + flags_checkboxes[0].find_element_by_xpath(".//input").get_attribute("id"), + "comment-check-flag", + ) + @skipIf(SKIP_SELENIUM, SKIP_SELENIUM_MESSAGE) def test_variant_filter_case_training_mode(self): """Test if submitting the filter yields the expected results.""" diff --git a/variants/tests/test_views.py b/variants/tests/test_views.py index c59b83706..145820897 100644 --- a/variants/tests/test_views.py +++ b/variants/tests/test_views.py @@ -60,6 +60,7 @@ CaddPathogenicityScoreCache, CaddSubmissionBgJob, SpanrSubmissionBgJob, + SmallVariantComment, ) from variants.queries import DeleteSmallVariantsQuery, DeleteStructuralVariantsQuery from variants.tests.factories import ( @@ -87,6 +88,7 @@ CaseWithVariantSetFactory, SmallVariantQueryFactory, RemoteSiteFactory, + MultiSmallVariantFlagsAndCommentFormDataFactory, ) from variants.tests.helpers import ViewTestBase from variants.tests.test_sync_upstream import load_isa_tab @@ -3841,6 +3843,438 @@ def test_user_cant_edit_admin_case_comment(self): ) +class TestMultiSmallVariantFlagsAndCommentApiView(ViewTestBase): + """Test MultiSmallVariantFlagsAndCommentApiView. + """ + + def setUp(self): + super().setUp() + self.case, self.variant_set, _ = CaseWithVariantSetFactory.get("small") + self.small_vars = SmallVariantFactory.create_batch(3, variant_set=self.variant_set) + self.small_vars_post = [ + { + "case": str(self.case.sodar_uuid), + "release": i.release, + "chromosome": i.chromosome, + "start": i.start, + "end": i.end, + "bin": i.end, + "reference": i.reference, + "alternative": i.alternative, + } + for i in self.small_vars + ] + + def test_get_json_response_two_non_existing(self): + with self.login(self.user): + self.assertEqual(SmallVariantFlags.objects.count(), 0) + response = self.client.get( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + {"variant_list": [json.dumps([self.small_vars_post[0], self.small_vars_post[1]])],}, + ) + self.assertEqual(SmallVariantFlags.objects.count(), 0) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "flags": { + "flag_bookmarked": None, + "flag_candidate": None, + "flag_final_causative": None, + "flag_for_validation": None, + "flag_no_disease_association": None, + "flag_segregates": None, + "flag_doesnt_segregate": None, + "flag_visual": None, + "flag_molecular": None, + "flag_validation": None, + "flag_phenotype_match": None, + "flag_summary": None, + }, + "flags_interfering": [], + "variant_list": [self.small_vars_post[0], self.small_vars_post[1],], + }, + ) + + def test_get_json_response_one_existing_one_non_existing(self): + with self.login(self.user): + # Create variant + data = vars(MultiSmallVariantFlagsAndCommentFormDataFactory(text="")) + self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data, + "variant_list": json.dumps([self.small_vars_post[0],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + # Query variant + response = self.client.get( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + {"variant_list": [json.dumps([self.small_vars_post[0], self.small_vars_post[1]])],}, + ) + data.pop("text") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "flags": data, + "flags_interfering": [], + "variant_list": [self.small_vars_post[0], self.small_vars_post[1],], + }, + ) + + def test_get_json_response_two_existing_non_conflicting_flags(self): + with self.login(self.user): + # Create variant + data = vars(MultiSmallVariantFlagsAndCommentFormDataFactory(text="")) + self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data, + "variant_list": json.dumps([self.small_vars_post[0], self.small_vars_post[1],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + # Query variant + response = self.client.get( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + {"variant_list": [json.dumps([self.small_vars_post[0], self.small_vars_post[1]])],}, + ) + data.pop("text") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "flags": data, + "flags_interfering": [], + "variant_list": [self.small_vars_post[0], self.small_vars_post[1],], + }, + ) + + def test_get_json_response_two_existing_conflicting_flags(self): + with self.login(self.user): + # Create variant + data1 = vars(MultiSmallVariantFlagsAndCommentFormDataFactory(text="")) + data2 = vars( + MultiSmallVariantFlagsAndCommentFormDataFactory( + flag_candidate=True, flag_visual="positive", text="", + ) + ) + self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data1, + "variant_list": json.dumps([self.small_vars_post[0],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data2, + "variant_list": json.dumps([self.small_vars_post[1],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + # Query variant + response = self.client.get( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + {"variant_list": [json.dumps([self.small_vars_post[0], self.small_vars_post[1]])],}, + ) + data1.pop("text") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "flags": data1, + "flags_interfering": sorted(["flag_visual", "flag_candidate"]), + "variant_list": [self.small_vars_post[0], self.small_vars_post[1],], + }, + ) + + def test_post_json_response_two_non_existing(self): + with self.login(self.user): + self.assertEqual(SmallVariantFlags.objects.count(), 0) + self.assertEqual(SmallVariantComment.objects.count(), 0) + data = vars(MultiSmallVariantFlagsAndCommentFormDataFactory(text="")) + response = self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data, + "variant_list": json.dumps([self.small_vars_post[0], self.small_vars_post[1],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + self.assertEqual(SmallVariantFlags.objects.count(), 2) + self.assertEqual(SmallVariantComment.objects.count(), 0) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "message": "OK", + "flags": {k: str(v) for k, v in data.items() if not k == "text"}, + "comment": "", + }, + ) + + def test_post_json_response_one_existing_one_non_existing(self): + with self.login(self.user): + # Create variant + data1 = vars(MultiSmallVariantFlagsAndCommentFormDataFactory(text="")) + data2 = vars( + MultiSmallVariantFlagsAndCommentFormDataFactory( + text="", flag_visual="positive", flag_candidate=True + ) + ) + self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data1, + "variant_list": json.dumps([self.small_vars_post[0],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + self.assertEqual(SmallVariantFlags.objects.count(), 1) + # Actual test + response = self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data2, + "variant_list": json.dumps([self.small_vars_post[0], self.small_vars_post[1],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(SmallVariantFlags.objects.count(), 2) + self.assertEqual( + response.json(), + { + "message": "OK", + "flags": {k: str(v) for k, v in data2.items() if not k == "text"}, + "comment": "", + }, + ) + + def test_post_json_response_two_existing_non_conflicting_flags(self): + with self.login(self.user): + # Create variant + data1 = vars(MultiSmallVariantFlagsAndCommentFormDataFactory(text="")) + data2 = vars( + MultiSmallVariantFlagsAndCommentFormDataFactory( + text="", flag_visual="positive", flag_candidate=True + ) + ) + self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data1, + "variant_list": json.dumps([self.small_vars_post[0], self.small_vars_post[1],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + # Actual test + response = self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data2, + "variant_list": json.dumps([self.small_vars_post[0],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "message": "OK", + "flags": {k: str(v) for k, v in data2.items() if not k == "text"}, + "comment": "", + }, + ) + + def test_post_json_response_two_existing_conflicting_flags(self): + with self.login(self.user): + # Create variant + data1 = vars(MultiSmallVariantFlagsAndCommentFormDataFactory(text="")) + data2 = vars( + MultiSmallVariantFlagsAndCommentFormDataFactory( + flag_candidate=True, flag_visual="positive", text="", + ) + ) + data3 = vars( + MultiSmallVariantFlagsAndCommentFormDataFactory( + flag_molecular="negative", flag_final_causative=True, text="", + ) + ) + self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data1, + "variant_list": json.dumps([self.small_vars_post[0],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data2, + "variant_list": json.dumps([self.small_vars_post[1],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + # Perform test + response = self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data3, + "variant_list": json.dumps([self.small_vars_post[0], self.small_vars_post[1]]), + "csrfmiddlewaretoken": "xxx", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "message": "OK", + "flags": {k: str(v) for k, v in data3.items() if not k == "text"}, + "comment": "", + }, + ) + + def test_post_json_response_add_comment(self): + with self.login(self.user): + self.assertEqual(SmallVariantFlags.objects.count(), 0) + self.assertEqual(SmallVariantComment.objects.count(), 0) + data = vars(MultiSmallVariantFlagsAndCommentFormDataFactory()) + response = self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data, + "variant_list": json.dumps([self.small_vars_post[0], self.small_vars_post[1],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + self.assertEqual(SmallVariantFlags.objects.count(), 2) + self.assertEqual(SmallVariantComment.objects.count(), 2) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "message": "OK", + "flags": {k: str(v) for k, v in data.items() if not k == "text"}, + "comment": "Comment X", + }, + ) + + def test_post_remove_flags(self): + with self.login(self.user): + self.assertEqual(SmallVariantFlags.objects.count(), 0) + data1 = vars(MultiSmallVariantFlagsAndCommentFormDataFactory(text="")) + data2 = vars( + MultiSmallVariantFlagsAndCommentFormDataFactory(text="", flag_bookmarked=False) + ) + # Create flags + self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data1, + "variant_list": json.dumps([self.small_vars_post[0], self.small_vars_post[1],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + self.assertEqual(SmallVariantFlags.objects.count(), 2) + # Delete flags + response = self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **data2, + "variant_list": json.dumps([self.small_vars_post[0], self.small_vars_post[1],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + self.assertEqual(SmallVariantFlags.objects.count(), 0) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "message": "OK", + "flags": {k: str(v) for k, v in data2.items() if not k == "text"}, + "comment": "", + }, + ) + + def test_post_provoke_form_error(self): + with self.login(self.user), self.assertRaises(Exception): + self.client.post( + reverse( + "variants:multi-small-variant-flags-comment-api", + kwargs={"project": self.case.project.sodar_uuid}, + ), + { + **vars(MultiSmallVariantFlagsAndCommentFormDataFactory(flag_bookmarker=100)), + "variant_list": json.dumps([self.small_vars_post[0], self.small_vars_post[1],]), + "csrfmiddlewaretoken": "xxx", + }, + ) + + class TestSmallVariantCommentDeleteApiView(RoleAssignmentMixin, ViewTestBase): """Test CaseCommentsDeleteApiView.""" diff --git a/variants/urls.py b/variants/urls.py index cff28f746..fa34bd217 100644 --- a/variants/urls.py +++ b/variants/urls.py @@ -222,6 +222,11 @@ view=views.SmallVariantCommentSubmitApiView.as_view(), name="small-variant-comment-api", ), + url( + regex=r"^(?P[0-9a-f-]+)/case/multi-small-variant-flags-comment/$", + view=views.MultiSmallVariantFlagsAndCommentApiView.as_view(), + name="multi-small-variant-flags-comment-api", + ), url( regex=r"^(?P[0-9a-f-]+)/case/small-variant-comment-delete/(?P[0-9a-f-]+)/$", view=views.SmallVariantCommentDeleteApiView.as_view(), diff --git a/variants/views.py b/variants/views.py index ca1d87a32..aa80e0978 100644 --- a/variants/views.py +++ b/variants/views.py @@ -4020,6 +4020,145 @@ def post(self, *_args, **_kwargs): return HttpResponse(json.dumps({"comment": comment.text}), content_type="application/json") +class MultiSmallVariantFlagsAndCommentApiView( + LoginRequiredMixin, LoggedInPermissionMixin, ProjectPermissionMixin, ProjectContextMixin, View, +): + """A view that returns JSON for the ``SmallVariantFlags`` for a variant of a case and allows updates.""" + + # TODO: create new permission + permission_required = "variants.view_data" + + def get(self, *_args, **_kwargs): + get_data = dict(self.request.GET) + variant_list = json.loads(get_data.get("variant_list")[0]) + flags_keys = [ + "flag_bookmarked", + "flag_candidate", + "flag_final_causative", + "flag_for_validation", + "flag_no_disease_association", + "flag_segregates", + "flag_doesnt_segregate", + "flag_visual", + "flag_molecular", + "flag_validation", + "flag_phenotype_match", + "flag_summary", + ] + flags = {i: None for i in flags_keys} + flags_interfering = set() + + for variant in reversed(variant_list): + case = get_object_or_404(Case, sodar_uuid=variant.get("case")) + + try: + flag_data = model_to_dict( + case.small_variant_flags.get( + release=variant.get("release"), + chromosome=variant.get("chromosome"), + start=variant.get("start"), + end=variant.get("end"), + reference=variant.get("reference"), + alternative=variant.get("alternative"), + ) + ) + + for flag in flags_keys: + if flags[flag] is None: + flags[flag] = flag_data[flag] + + if not flags[flag] == flag_data[flag]: + flags_interfering.add(flag) + + flags[flag] = flag_data[flag] + + except SmallVariantFlags.DoesNotExist: + continue + + results = { + "flags": flags, + "flags_interfering": sorted(flags_interfering), + "variant_list": variant_list, + } + + return JsonResponse(results, UUIDEncoder) + + def post(self, *_args, **_kwargs): + timeline = get_backend_api("timeline_backend") + post_data = dict(self.request.POST) + variant_list = json.loads(post_data.pop("variant_list")[0]) + post_data.pop("csrfmiddlewaretoken") + post_data_clean = {k: v[0] for k, v in post_data.items()} + text = post_data_clean.pop("text") + + for variant in variant_list: + case = get_object_or_404(Case, sodar_uuid=variant.get("case")) + + try: + flags = case.small_variant_flags.get( + release=variant.get("release"), + chromosome=variant.get("chromosome"), + start=variant.get("start"), + end=variant.get("end"), + reference=variant.get("reference"), + alternative=variant.get("alternative"), + ) + + except SmallVariantFlags.DoesNotExist: + flags = SmallVariantFlags(case=case) + + form = SmallVariantFlagsForm({**variant, **post_data_clean}, instance=flags) + + try: + flags = form.save() + + except ValueError as e: + raise Exception(str(form.errors)) from e + + if timeline: + tl_event = timeline.add_event( + project=self.get_project(self.request, self.kwargs), + app_name="variants", + user=self.request.user, + event_name="flags_set", + description="set flags for variant %s in case {case}: {extra-flag_values}" + % flags.get_variant_description(), + status_type="OK", + extra_data={"flag_values": flags.human_readable()}, + ) + tl_event.add_object(obj=case, label="case", name=case.name) + + if flags.no_flags_set(): + flags.delete() + + if text: + comment = SmallVariantComment( + case=case, user=self.request.user, sodar_uuid=uuid.uuid4() + ) + form = SmallVariantCommentForm({**variant, "text": text}, instance=comment) + + try: + comment = form.save() + + except ValueError as e: + raise Exception(str(form.errors)) from e + + if timeline: + tl_event = timeline.add_event( + project=self.get_project(self.request, self.kwargs), + app_name="variants", + user=self.request.user, + event_name="variant_comment_add", + description="add comment for variant %s in case {case}: {text}" + % comment.get_variant_description(), + status_type="OK", + ) + tl_event.add_object(obj=case, label="case", name=case.name) + tl_event.add_object(obj=comment, label="text", name=comment.shortened_text()) + + return JsonResponse({"message": "OK", "flags": post_data_clean, "comment": text}) + + class SmallVariantCommentDeleteApiView( LoginRequiredMixin, LoggedInPermissionMixin,