1
|
|
|
# -*- coding: utf-8 -*- |
2
|
|
|
|
3
|
|
|
from django.conf import settings |
4
|
|
|
from django.core.exceptions import ObjectDoesNotExist |
5
|
|
|
from django.db import models |
6
|
|
|
from django.urls import reverse |
7
|
|
|
from tree_queries.models import TreeNode |
8
|
|
|
from uuslug import slugify |
9
|
|
|
|
10
|
|
|
from tcms.core.history import KiwiHistoricalRecords |
11
|
|
|
from tcms.core.models.base import UrlMixin |
12
|
|
|
from tcms.management.models import Version |
13
|
|
|
from tcms.testcases.models import TestCasePlan |
14
|
|
|
|
15
|
|
|
|
16
|
|
|
class PlanType(models.Model, UrlMixin): |
17
|
|
|
name = models.CharField(max_length=64, unique=True) |
18
|
|
|
description = models.TextField(blank=True, null=True) |
19
|
|
|
|
20
|
|
|
def __str__(self): |
21
|
|
|
return self.name |
22
|
|
|
|
23
|
|
|
class Meta: |
24
|
|
|
ordering = ["name"] |
25
|
|
|
|
26
|
|
|
|
27
|
|
|
class TestPlan(TreeNode, UrlMixin): |
28
|
|
|
"""A plan within the TCMS""" |
29
|
|
|
|
30
|
|
|
history = KiwiHistoricalRecords() |
31
|
|
|
|
32
|
|
|
name = models.CharField(max_length=255, db_index=True) |
33
|
|
|
text = models.TextField(blank=True) |
34
|
|
|
create_date = models.DateTimeField(auto_now_add=True) |
35
|
|
|
is_active = models.BooleanField(default=True, db_index=True) |
36
|
|
|
extra_link = models.CharField(max_length=1024, default=None, blank=True, null=True) |
37
|
|
|
|
38
|
|
|
product_version = models.ForeignKey( |
39
|
|
|
Version, related_name="plans", on_delete=models.CASCADE |
40
|
|
|
) |
41
|
|
|
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) |
42
|
|
|
product = models.ForeignKey( |
43
|
|
|
"management.Product", related_name="plan", on_delete=models.CASCADE |
44
|
|
|
) |
45
|
|
|
type = models.ForeignKey(PlanType, on_delete=models.CASCADE) |
46
|
|
|
tag = models.ManyToManyField( |
47
|
|
|
"management.Tag", through="testplans.TestPlanTag", related_name="plan" |
48
|
|
|
) |
49
|
|
|
|
50
|
|
|
def __str__(self): |
51
|
|
|
return self.name |
52
|
|
|
|
53
|
|
|
def add_case(self, case, sortkey=None): |
54
|
|
|
if sortkey is None: |
55
|
|
|
lastcase = self.testcaseplan_set.order_by("-sortkey").first() |
56
|
|
|
if lastcase and lastcase.sortkey is not None: |
57
|
|
|
sortkey = lastcase.sortkey + 10 |
58
|
|
|
else: |
59
|
|
|
sortkey = 0 |
60
|
|
|
|
61
|
|
|
return TestCasePlan.objects.get_or_create( |
62
|
|
|
plan=self, case=case, defaults={"sortkey": sortkey} |
63
|
|
|
)[0] |
64
|
|
|
|
65
|
|
|
def add_tag(self, tag): |
66
|
|
|
return TestPlanTag.objects.get_or_create(plan=self, tag=tag) |
67
|
|
|
|
68
|
|
|
def remove_tag(self, tag): |
69
|
|
|
TestPlanTag.objects.filter(plan=self, tag=tag).delete() |
70
|
|
|
|
71
|
|
|
def delete_case(self, case): |
72
|
|
|
TestCasePlan.objects.filter(case=case.pk, plan=self.pk).delete() |
73
|
|
|
|
74
|
|
|
def _get_absolute_url(self): |
75
|
|
|
return reverse("test_plan_url", args=[self.pk, slugify(self.name)]) |
76
|
|
|
|
77
|
|
|
def get_absolute_url(self): |
78
|
|
|
return self._get_absolute_url() |
79
|
|
|
|
80
|
|
|
def get_full_url(self): |
81
|
|
|
return super().get_full_url().rstrip("/") |
82
|
|
|
|
83
|
|
|
def _get_email_conf(self): |
84
|
|
|
try: |
85
|
|
|
# note: this is the reverse_name of a 1-to-1 field |
86
|
|
|
return self.email_settings # pylint: disable=no-member |
87
|
|
|
except ObjectDoesNotExist: |
88
|
|
|
return TestPlanEmailSettings.objects.create(plan=self) |
89
|
|
|
|
90
|
|
|
emailing = property(_get_email_conf) |
91
|
|
|
|
92
|
|
|
def make_cloned_name(self): |
93
|
|
|
"""Make default name of cloned plan""" |
94
|
|
|
return f"Copy of {self.name}" |
95
|
|
|
|
96
|
|
|
def clone( # pylint: disable=too-many-arguments |
97
|
|
|
self, |
98
|
|
|
name=None, |
99
|
|
|
product=None, |
100
|
|
|
version=None, |
101
|
|
|
new_author=None, |
102
|
|
|
set_parent=False, |
103
|
|
|
copy_testcases=False, |
104
|
|
|
**_kwargs, |
105
|
|
|
): |
106
|
|
|
"""Clone this plan |
107
|
|
|
|
108
|
|
|
:param name: New name of cloned plan. If not passed, make_cloned_name is called |
109
|
|
|
to generate a default one. |
110
|
|
|
:type name: str |
111
|
|
|
:param product: Product of cloned plan. If not passed, original plan's product is used. |
112
|
|
|
:type product: :class:`tcms.management.models.Product` |
113
|
|
|
:param version: Product version of cloned plan. If not passed use from source plan. |
114
|
|
|
:type version: :class:`tcms.management.models.Version` |
115
|
|
|
:param new_author: New author of cloned plan. If not passed, original plan's |
116
|
|
|
author is used. |
117
|
|
|
:type new_author: settings.AUTH_USER_MODEL |
118
|
|
|
:param set_parent: Whether to set original plan as parent of cloned plan. |
119
|
|
|
Default is False. |
120
|
|
|
:type set_parent: bool |
121
|
|
|
:param copy_testcases: Whether to copy cases to cloned plan instead of just |
122
|
|
|
linking them. Default is False. |
123
|
|
|
:type copy_testcases: bool |
124
|
|
|
:param \\**_kwargs: Unused catch-all variable container for any extra input |
125
|
|
|
which may be present |
126
|
|
|
:return: cloned plan |
127
|
|
|
:rtype: :class:`tcms.testplans.models.TestPlan` |
128
|
|
|
""" |
129
|
|
|
tp_dest = TestPlan.objects.create( |
130
|
|
|
name=name or self.make_cloned_name(), |
131
|
|
|
product=product or self.product, |
132
|
|
|
author=new_author or self.author, |
133
|
|
|
type=self.type, |
134
|
|
|
product_version=version or self.product_version, |
135
|
|
|
create_date=self.create_date, |
136
|
|
|
is_active=self.is_active, |
137
|
|
|
extra_link=self.extra_link, |
138
|
|
|
parent=self if set_parent else None, |
139
|
|
|
text=self.text, |
140
|
|
|
) |
141
|
|
|
|
142
|
|
|
# Copy the plan tags |
143
|
|
|
for tp_tag_src in self.tag.all(): |
144
|
|
|
tp_dest.add_tag(tag=tp_tag_src) |
145
|
|
|
|
146
|
|
|
# include TCs inside cloned TP |
147
|
|
|
qs = self.cases.all().annotate(sortkey=models.F("testcaseplan__sortkey")) |
148
|
|
|
for tc_src in qs: |
149
|
|
|
# this parameter should really be named clone_testcases b/c if set |
150
|
|
|
# it clones the source TC and then adds it to the new TP |
151
|
|
|
if copy_testcases: |
152
|
|
|
tc_src.clone(new_author, [tp_dest]) |
153
|
|
|
else: |
154
|
|
|
# otherwise just link the existing TC to the new TP |
155
|
|
|
tp_dest.add_case(tc_src, sortkey=tc_src.sortkey) |
156
|
|
|
|
157
|
|
|
return tp_dest |
158
|
|
|
|
159
|
|
|
def tree_as_list(self): |
160
|
|
|
""" |
161
|
|
|
Returns the entire tree family as a list of TestPlan |
162
|
|
|
object with additional fields from tree_queries! |
163
|
|
|
""" |
164
|
|
|
plan = TestPlan.objects.with_tree_fields().get(pk=self.pk) |
165
|
|
|
|
166
|
|
|
tree_root = plan.ancestors(include_self=True).first() |
167
|
|
|
result = tree_root.descendants(include_self=True) |
168
|
|
|
|
169
|
|
|
return result |
170
|
|
|
|
171
|
|
|
def tree_view_html(self): |
172
|
|
|
""" |
173
|
|
|
Returns nested tree structure represented as Patterfly TreeView! |
174
|
|
|
Relies on the fact that tree nodes are returned in DFS |
175
|
|
|
order! |
176
|
|
|
""" |
177
|
|
|
tree_nodes = self.tree_as_list() |
178
|
|
|
|
179
|
|
|
# TP is not part of a tree |
180
|
|
|
if len(tree_nodes) == 1: |
181
|
|
|
return "" |
182
|
|
|
|
183
|
|
|
result = "" |
184
|
|
|
previous_depth = -1 |
185
|
|
|
|
186
|
|
|
for test_plan in tree_nodes: |
187
|
|
|
# close tags for previously rendered node before rendering current one |
188
|
|
|
if test_plan.tree_depth == previous_depth: |
189
|
|
|
result += """ |
190
|
|
|
</div><!-- end-subtree --> |
191
|
|
|
</div> <!-- end-node -->""" |
192
|
|
|
|
193
|
|
|
# indent |
194
|
|
|
if test_plan.tree_depth > previous_depth: |
195
|
|
|
previous_depth = test_plan.tree_depth |
196
|
|
|
|
197
|
|
|
# outdent |
198
|
|
|
did_outdent = False |
199
|
|
|
while test_plan.tree_depth < previous_depth: |
200
|
|
|
result += """ |
201
|
|
|
</div><!-- end-subtree --> |
202
|
|
|
</div> <!-- end-node -->""" |
203
|
|
|
previous_depth -= 1 |
204
|
|
|
did_outdent = True |
205
|
|
|
|
206
|
|
|
if did_outdent: |
207
|
|
|
result += """ |
208
|
|
|
</div><!-- end-subtree --> |
209
|
|
|
</div> <!-- end-node -->""" |
210
|
|
|
|
211
|
|
|
# render the current node |
212
|
|
|
active_class = "" |
213
|
|
|
if test_plan.pk == self.pk: |
214
|
|
|
active_class = "active" |
215
|
|
|
|
216
|
|
|
result += f""" |
217
|
|
|
<!-- begin-node --> |
218
|
|
|
<div class="list-group-item {active_class}" style="border: none"> |
219
|
|
|
<div class="list-group-item-header" style="padding:0"> |
220
|
|
|
<div class="list-view-pf-main-info" |
221
|
|
|
style="padding-top:0; padding-bottom:0"> |
222
|
|
|
<div class="list-view-pf-left" |
223
|
|
|
style="margin-left:3px; padding-right:10px"> |
224
|
|
|
<span class="fa fa-angle-right"></span> |
225
|
|
|
</div> |
226
|
|
|
|
227
|
|
|
<div class="list-view-pf-body"> |
228
|
|
|
<div class="list-view-pf-description"> |
229
|
|
|
<div class="list-group-item-text"> |
230
|
|
|
<a href="{test_plan.get_absolute_url()}"> |
231
|
|
|
TP-{test_plan.pk}: {test_plan.name} |
232
|
|
|
</a> |
233
|
|
|
</div> |
234
|
|
|
</div> |
235
|
|
|
</div> |
236
|
|
|
</div> |
237
|
|
|
</div> <!-- /header --> |
238
|
|
|
|
239
|
|
|
<!-- begin-subtree --> |
240
|
|
|
<div class="list-group-item-container container-fluid" style="border: none"> |
241
|
|
|
""" |
242
|
|
|
|
243
|
|
|
# close after the last elements in the for loop |
244
|
|
|
while previous_depth >= 0: |
245
|
|
|
result += """ |
246
|
|
|
</div><!-- end-subtree --> |
247
|
|
|
</div> <!-- end-node -->""" |
248
|
|
|
previous_depth -= 1 |
249
|
|
|
|
250
|
|
|
# HTML sanity check |
251
|
|
|
begin_node = result.count("<!-- begin-node -->") |
252
|
|
|
end_node = result.count("<!-- end-node -->") |
253
|
|
|
|
254
|
|
|
begin_subtree = result.count("<!-- begin-subtree -->") |
255
|
|
|
end_subtree = result.count("<!-- end-subtree -->") |
256
|
|
|
|
257
|
|
|
# tese will make sure that we catch errors in production |
258
|
|
|
if begin_node != end_node: |
259
|
|
|
raise RuntimeError("Begin/End count for tree-view nodes don't match") |
260
|
|
|
|
261
|
|
|
if begin_subtree != end_subtree: |
262
|
|
|
raise RuntimeError("Begin/End count for tree-view subtrees don't match") |
263
|
|
|
|
264
|
|
|
return f""" |
265
|
|
|
<div id="test-plan-family-tree" |
266
|
|
|
class="list-group tree-list-view-pf" |
267
|
|
|
style="margin-top:0"> |
268
|
|
|
{result} |
269
|
|
|
</div> |
270
|
|
|
""" |
271
|
|
|
|
272
|
|
|
|
273
|
|
|
class TestPlanTag(models.Model): |
274
|
|
|
tag = models.ForeignKey("management.Tag", on_delete=models.CASCADE) |
275
|
|
|
plan = models.ForeignKey(TestPlan, on_delete=models.CASCADE) |
276
|
|
|
|
277
|
|
|
|
278
|
|
|
class TestPlanEmailSettings(models.Model): |
279
|
|
|
plan = models.OneToOneField( |
280
|
|
|
TestPlan, related_name="email_settings", on_delete=models.CASCADE |
281
|
|
|
) |
282
|
|
|
auto_to_plan_author = models.BooleanField(default=True) |
283
|
|
|
auto_to_case_owner = models.BooleanField(default=True) |
284
|
|
|
auto_to_case_default_tester = models.BooleanField(default=True) |
285
|
|
|
notify_on_plan_update = models.BooleanField(default=True) |
286
|
|
|
notify_on_case_update = models.BooleanField(default=True) |
287
|
|
|
|