Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions tcms/testplans/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,33 @@ def populate(self, product_pk):
)
else:
self.fields["version"].queryset = Version.objects.none()


class CloneMultiPlanForm(forms.Form): # pylint: disable=must-inherit-from-model-form
plan = forms.ModelMultipleChoiceField(
queryset=TestPlan.objects.all(),
)

product = forms.ModelChoiceField(
queryset=Product.objects.all(),
empty_label=None,
required=False,
)
version = forms.ModelChoiceField(
queryset=Version.objects.none(),
empty_label=None,
required=False,
)

copy_testcases = forms.BooleanField(required=False)
set_parent = forms.BooleanField(required=False)

def populate(self, plans):
if plans:
product_pk = TestPlan.objects.filter(pk__in=plans[0])[0].product_id
self.fields["version"].queryset = Version.objects.filter(
product_id=product_pk
)
self.fields["plan"].queryset = TestPlan.objects.filter(pk__in=plans)
else:
self.fields["version"].queryset = Version.objects.none()
82 changes: 80 additions & 2 deletions tcms/testplans/static/testplans/js/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@
initializeDateTimePicker('#id_before')
initializeDateTimePicker('#id_after')

const multiCloneButton = {
text: '<i class="fa fa-code-fork"></i>',
titleAttr: 'Clone Selected',
action: function (e, dt, node, config) {
const selectedTestPlans = getSelectedTestPlans()
if (selectedTestPlans.length === 0) {
alert($('#main-element').data('trans-no-testplans-selected'))
return false
}

window.location.assign(`/plan/clone/?p=${selectedTestPlans.join('&p=')}`)
Comment thread Fixed

Check warning

Code scanning / CodeQL

DOM text reinterpreted as HTML Medium test

DOM text
is reinterpreted as HTML without escaping meta-characters.
}
}

const rowsNotShownMessage = $('#main-element').data('trans-some-rows-not-shown')
const table = $('#resultsTable').DataTable({
pageLength: $('#navbar').data('defaultpagesize'),
Expand Down Expand Up @@ -93,6 +107,11 @@
dataTableJsonRPC('TestPlan.filter', params, callbackF, preProcessData)
},
columns: [
{
data: null,
orderable: false,
render: function () { return '<input type="checkbox" class="row-select">' }
},
{
data: null,
defaultContent: '',
Expand Down Expand Up @@ -159,13 +178,37 @@
})
},
dom: 'Bptp',
buttons: exportButtons,
buttons: [
multiCloneButton,
...exportButtons
],
language: {
loadingRecords: '<div class="spinner spinner-lg"></div>',
processing: '<div class="spinner spinner-lg"></div>',
zeroRecords: 'No records found'
},
order: [[1, 'asc']]
order: [[2, 'asc']],
initComplete: function () {
const btnContainer = table.buttons().container()
$(btnContainer).prepend(
'<input type="checkbox" id="onlyActive" style=" margin: 0 10px;">'
)

// Hook the checkbox change event to “select all”
$('#onlyActive').on('change', function () {
const checked = this.checked
$('#resultsTable tbody input.row-select')
.prop('checked', checked)
.trigger('change')
})
}
})

// row checkbox handler
$('#resultsTable tbody').on('change', 'input.row-select', function () {
const $tr = $(this).closest('tr')
if (this.checked) table.row($tr).select()
else table.row($tr).deselect()
})

// Add event listener for opening and closing nested test plans
Expand All @@ -192,6 +235,36 @@
$('#id_product').change(updateVersionSelectFromProduct)
}

function getSelectedTestPlans () {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm, this feels like a duplicate. Need to check if we don't have something more generic which already does this.

In testplans/static/testplans/js/get.js there is getSelectedTestCases() and IIRC there was another one in utils.js but let's leave this for later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main differences between getSelectedTestPlans() and getSelectedTestCases() is that getplans has the handle selecting child nested elements where getcases does not do this.

const inputs = $('#resultsTable tbody input.row-select:checked').closest('tr:visible')
const tpIds = []

inputs.each(function (_, el) {
// Check if the row has collapsed children and add their IDs
tpIds.push(...getChildRows(el))
})
return tpIds
}

function getChildRows (parentRowId) {
const tpIds = []
const parentRow = $('#resultsTable').DataTable().row($(parentRowId).closest('tr'))
const id = $(parentRowId).closest('tr').find('td:nth-child(3)').text().trim()
const children = hiddenChildRows[id]

if (id) {
tpIds.push(id)
}

if (children && !parentRow.child.isShown()) {
children.forEach(function (childRow) {
tpIds.push(...getChildRows(childRow))
})
return tpIds
}
return tpIds
}

function hideExpandedChildren (table, parentRow) {
const children = hiddenChildRows[parentRow.data().id]
children.forEach(
Expand All @@ -214,5 +287,10 @@
// this is an array of previously hidden rows
const children = hiddenChildRows[data.id]
$(children).find('td').css('border', '0').css('padding-left', `${childPadding}px`)

if ($(parentRow).find('input.row-select').prop('checked')) {
$(children).find('input.row-select').prop('checked', true).trigger('change')
}

return $(children).show()
}
77 changes: 77 additions & 0 deletions tcms/testplans/templates/testplans/multi_clone.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}

{% block title %}{% trans "Clone TestPlan" %} - {{ test_plan.name }}{% endblock %}
{% block page_id %}page-testplans-mutable{% endblock %}

{% block contents %}
<div class="container-fluid container-cards-pf">
<form class="form-horizontal" method="post" action="{% url "plans-multi-clone" %}">
{% csrf_token %}

{% for plan in form.plan.field.queryset %}
<input type="hidden" name="plan" value="{{ plan.pk }}">
{% endfor %}

<div class="form-group">
<div class="col-md-1 col-lg-1">
<label for="id_product">{% trans "Product" %}</label>
<a href="{% url 'admin:management_product_add' %}?_popup" id="add_id_product" alt="{% trans 'add new Product' %}" title="{% trans 'add new Product' %}">+</a>
</div>
<div class="col-md-3 col-lg-3 {% if form.product.errors %}has-error{% endif %}">
<select id="id_product" name="product" class="form-control selectpicker">
{% for product in form.product.field.queryset %}
<option value="{{ product.pk }}" {% if product.pk|escape == form.product.value|escape %}selected{% endif %}>
{{ product.name }}
</option>
{% endfor %}
</select>
{{ form.product.errors }}
</div>

<div class="col-md-1 col-lg-1">
<label for="id_version">{% trans "Version" %}</label>
<a href="{% url 'admin:management_version_add' %}?_popup&product={{ form.product.value }}"
id="add_id_version" alt="{% trans 'add new Version' %}" title="{% trans 'add new Version' %}">
+
</a>
</div>
<div class="col-md-3 col-lg-3 {% if form.version.errors %}has-error{% endif %}">
<select id="id_version" name="version" class="form-control selectpicker">
{% for product_version in form.version.field.queryset %}
<option value="{{ product_version.pk }}" {% if product_version.pk|escape == form.version.value|escape %}selected{% endif %}>
{{ product_version.value }}
</option>
{% endfor %}
</select>
{{ form.version.errors }}
</div>
</div>

<div class="form-group">
<label class="col-md-1 col-lg-1" for="id_copy_testcases">{% trans "Clone TCs" %}</label>
<div class="col-md-3 col-lg-3">
<input class="bootstrap-switch" id="id_copy_testcases" name="copy_testcases"
type="checkbox" {% if form.copy_testcases.value %}checked{% endif %} data-on-text="{% trans 'Yes' %}" data-off-text="{% trans 'No' %}">
<p class="help-block">{% trans "Clone or link existing TCs into new TP" %}</p>
</div>


<label class="col-md-1 col-lg-1" for="id_set_parent">{% trans "Parent TP" %}</label>
<div class="col-md-3 col-lg-3">
<input class="bootstrap-switch" id="id_set_parent" name="set_parent"
type="checkbox" {% if form.set_parent.value %}checked{% endif %} data-on-text="{% trans 'Yes' %}" data-off-text="{% trans 'No' %}">
<p class="help-block">{% trans "Set the source TP as parent of new TP" %}</p>
</div>
</div>

<div class="form-group">
<div class="col-md-1 col-lg-1">
<button type="submit" class="btn btn-default btn-lg">{% trans "Clone" %}</button>
</div>
</div>
</form>

</div><!-- /container -->
{% endblock %}
2 changes: 2 additions & 0 deletions tcms/testplans/templates/testplans/search.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
class="container-fluid container-cards-pf"
id="main-element"
data-trans-some-rows-not-shown="{% trans 'Some child test plans do not match search criteria'%}"
data-trans-no-testplans-selected="{% trans 'No rows selected! Please select at least one!'%}"
>
<form class="form-horizontal" method="get">
{% csrf_token %}
Expand Down Expand Up @@ -110,6 +111,7 @@
<table class="table table-striped table-bordered table-hover" id="resultsTable">
<thead>
<tr>
<th></th>
<th></th>
<th>{% trans "ID" %}</th>
<th>{% trans "Test plan" %}</th>
Expand Down
1 change: 1 addition & 0 deletions tcms/testplans/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
),
re_path(r"^(?P<pk>\d+)/edit/$", views.Edit.as_view(), name="plan-edit"),
re_path(r"^(?P<pk>\d+)/clone/$", views.Clone.as_view(), name="plans-clone"),
re_path(r"^clone/$", views.MultiClone.as_view(), name="plans-multi-clone"),
re_path(r"^search/$", views.SearchTestPlanView.as_view(), name="plans-search"),
re_path(r"^new/$", views.NewTestPlanView.as_view(), name="plans-new"),
]
64 changes: 64 additions & 0 deletions tcms/testplans/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-

from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.http import HttpResponsePermanentRedirect, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy as _
Expand All @@ -15,6 +17,7 @@
from tcms.management.models import Priority
from tcms.testcases.models import TestCaseStatus
from tcms.testplans.forms import (
CloneMultiPlanForm,
ClonePlanForm,
NewPlanForm,
PlanNotifyFormSet,
Expand Down Expand Up @@ -226,3 +229,64 @@
return HttpResponseRedirect(
reverse("test_plan_url_short", args=[cloned_plan.pk])
)


@method_decorator(permission_required("testplans.add_testplan"), name="dispatch")
class MultiClone(FormView):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not liking this one very much b/c it adds unnecessary code.

IMO all could be done with the existing cloning code but needs more attention to the details.

Let's sort the GUI part first and then will circle back to this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could remove the original single clone classes and code and change it to redirect to multiclone checking if there is only one and if so letting you change its name.

template_name = "testplans/multi_clone.html"
form_class = CloneMultiPlanForm

http_method_names = ["get", "post"]

def get(self, request): # pylint: disable=W0221
if not self._is_request_data_valid(request, "p"):
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium test

Untrusted URL redirection depends on a
user-provided value
.

get_params = request.GET.copy()
get_params.setlist("plan", request.GET.getlist("p"))
del get_params["p"]

planids = get_params.getlist("plan")

clone_form = CloneMultiPlanForm(get_params)
clone_form.populate(plans=planids)

context = {
"form": clone_form,
}
return render(request, self.template_name, context)

def post(self, request): # pylint: disable=W0221
if not self._is_request_data_valid(request):
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium test

Untrusted URL redirection depends on a
user-provided value
.

# Do the clone action
clone_form = CloneMultiPlanForm(request.POST)
clone_form.populate(plans=request.POST.getlist("plan"))

if clone_form.is_valid():

for tp_src in clone_form.cleaned_data["plan"]:
tp_src.clone(name=tp_src.name, **clone_form.cleaned_data)

# Otherwise tell the user the clone action is successful
messages.add_message(
request, messages.SUCCESS, _("TestPlan cloning was successful")
)
return HttpResponseRedirect(reverse("plans-search"))

# invalid form
messages.add_message(request, messages.ERROR, clone_form.errors)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium test

Untrusted URL redirection depends on a
user-provided value
.

@staticmethod
def _is_request_data_valid(request, field_name="plan"):
request_data = getattr(request, request.method)

if field_name not in request_data:
messages.add_message(
request, messages.ERROR, _("At least one TestPlan is required")
)
return False

return True
Loading