tcms.testplans.models   A
last analyzed

Complexity

Total Complexity 32

Size/Duplication

Total Lines 287
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 32
eloc 142
dl 0
loc 287
rs 9.84
c 0
b 0
f 0

14 Methods

Rating   Name   Duplication   Size   Complexity  
A TestPlan.__str__() 0 2 1
A TestPlan.add_tag() 0 2 1
A TestPlan.get_full_url() 0 2 1
A TestPlan.remove_tag() 0 2 1
A PlanType.__str__() 0 2 1
A TestPlan.add_case() 0 11 4
A TestPlan.get_absolute_url() 0 2 1
A TestPlan._get_absolute_url() 0 2 1
A TestPlan.delete_case() 0 2 1
A TestPlan.make_cloned_name() 0 3 1
A TestPlan._get_email_conf() 0 6 2
C TestPlan.tree_view_html() 0 94 11
B TestPlan.clone() 0 62 5
A TestPlan.tree_as_list() 0 11 1
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