1
|
|
|
# -*- coding: utf-8 -*- |
2
|
|
|
from datetime import timedelta |
3
|
|
|
|
4
|
|
|
import vinaigrette |
5
|
|
|
from django.conf import settings |
6
|
|
|
from django.db import models |
7
|
|
|
from django.db.models import ObjectDoesNotExist |
8
|
|
|
from django.urls import reverse |
9
|
|
|
from django.utils.translation import gettext_lazy as _ |
10
|
|
|
|
11
|
|
|
from tcms.core.history import KiwiHistoricalRecords |
12
|
|
|
from tcms.core.models.base import UrlMixin |
13
|
|
|
from tcms.testcases.fields import MultipleEmailField |
14
|
|
|
|
15
|
|
|
|
16
|
|
|
class TestCaseStatus(models.Model, UrlMixin): |
17
|
|
|
name = models.CharField(max_length=255) |
18
|
|
|
description = models.TextField(null=True, blank=True) |
19
|
|
|
is_confirmed = models.BooleanField(db_index=True, default=False) |
20
|
|
|
|
21
|
|
|
class Meta: |
22
|
|
|
verbose_name = _("Test case status") |
23
|
|
|
verbose_name_plural = _("Test case statuses") |
24
|
|
|
|
25
|
|
|
def __str__(self): |
26
|
|
|
return self.name |
27
|
|
|
|
28
|
|
|
|
29
|
|
|
# register model for DB translations |
30
|
|
|
vinaigrette.register(TestCaseStatus, ["name"]) |
31
|
|
|
|
32
|
|
|
|
33
|
|
|
class Category(models.Model, UrlMixin): |
34
|
|
|
name = models.CharField(max_length=255) |
35
|
|
|
product = models.ForeignKey( |
36
|
|
|
"management.Product", related_name="category", on_delete=models.CASCADE |
37
|
|
|
) |
38
|
|
|
description = models.TextField(blank=True) |
39
|
|
|
|
40
|
|
|
class Meta: |
41
|
|
|
verbose_name_plural = u"test case categories" |
42
|
|
|
unique_together = ("product", "name") |
43
|
|
|
|
44
|
|
|
def __str__(self): |
45
|
|
|
return self.name |
46
|
|
|
|
47
|
|
|
|
48
|
|
|
class TestCase(models.Model, UrlMixin): |
49
|
|
|
history = KiwiHistoricalRecords() |
50
|
|
|
|
51
|
|
|
create_date = models.DateTimeField(auto_now_add=True) |
52
|
|
|
is_automated = models.BooleanField(default=False) |
53
|
|
|
script = models.TextField(blank=True, null=True) |
54
|
|
|
arguments = models.TextField(blank=True, null=True) |
55
|
|
|
extra_link = models.CharField(max_length=1024, default=None, blank=True, null=True) |
56
|
|
|
summary = models.CharField(max_length=255, db_index=True) |
57
|
|
|
requirement = models.CharField(max_length=255, blank=True, null=True) |
58
|
|
|
notes = models.TextField(blank=True, null=True) |
59
|
|
|
text = models.TextField(blank=True) |
60
|
|
|
setup_duration = models.DurationField(db_index=True, null=True, blank=True) |
61
|
|
|
testing_duration = models.DurationField(db_index=True, null=True, blank=True) |
62
|
|
|
|
63
|
|
|
case_status = models.ForeignKey(TestCaseStatus, on_delete=models.CASCADE) |
64
|
|
|
category = models.ForeignKey( |
65
|
|
|
Category, related_name="category_case", on_delete=models.CASCADE |
66
|
|
|
) |
67
|
|
|
priority = models.ForeignKey( |
68
|
|
|
"management.Priority", related_name="priority_case", on_delete=models.CASCADE |
69
|
|
|
) |
70
|
|
|
author = models.ForeignKey( |
71
|
|
|
settings.AUTH_USER_MODEL, |
72
|
|
|
related_name="cases_as_author", |
73
|
|
|
on_delete=models.CASCADE, |
74
|
|
|
) |
75
|
|
|
default_tester = models.ForeignKey( |
76
|
|
|
settings.AUTH_USER_MODEL, |
77
|
|
|
related_name="cases_as_default_tester", |
78
|
|
|
blank=True, |
79
|
|
|
null=True, |
80
|
|
|
on_delete=models.CASCADE, |
81
|
|
|
) |
82
|
|
|
reviewer = models.ForeignKey( |
83
|
|
|
settings.AUTH_USER_MODEL, |
84
|
|
|
related_name="cases_as_reviewer", |
85
|
|
|
null=True, |
86
|
|
|
on_delete=models.CASCADE, |
87
|
|
|
) |
88
|
|
|
|
89
|
|
|
plan = models.ManyToManyField( |
90
|
|
|
"testplans.TestPlan", related_name="cases", through="testcases.TestCasePlan" |
91
|
|
|
) |
92
|
|
|
|
93
|
|
|
component = models.ManyToManyField( |
94
|
|
|
"management.Component", |
95
|
|
|
related_name="cases", |
96
|
|
|
through="testcases.TestCaseComponent", |
97
|
|
|
) |
98
|
|
|
|
99
|
|
|
tag = models.ManyToManyField( |
100
|
|
|
"management.Tag", related_name="case", through="testcases.TestCaseTag" |
101
|
|
|
) |
102
|
|
|
|
103
|
|
|
@property |
104
|
|
|
def expected_duration(self): |
105
|
|
|
result = timedelta(0) |
106
|
|
|
result += self.setup_duration or timedelta(0) |
107
|
|
|
result += self.testing_duration or timedelta(0) |
108
|
|
|
return result |
109
|
|
|
|
110
|
|
|
def __str__(self): |
111
|
|
|
return self.summary |
112
|
|
|
|
113
|
|
|
def add_component(self, component): |
114
|
|
|
return TestCaseComponent.objects.get_or_create(case=self, component=component) |
115
|
|
|
|
116
|
|
|
def add_tag(self, tag): |
117
|
|
|
return TestCaseTag.objects.get_or_create(case=self, tag=tag) |
118
|
|
|
|
119
|
|
|
def get_text_with_version(self, case_text_version=None): |
120
|
|
|
if case_text_version: |
121
|
|
|
try: |
122
|
|
|
return self.history.get( # pylint: disable=no-member |
123
|
|
|
history_id=case_text_version |
124
|
|
|
).text |
125
|
|
|
except ObjectDoesNotExist: |
126
|
|
|
return self.text |
127
|
|
|
|
128
|
|
|
return self.text |
129
|
|
|
|
130
|
|
|
def remove_component(self, component): |
131
|
|
|
# note: cannot use self.component.remove(component) on a ManyToManyField |
132
|
|
|
# which specifies an intermediary model so we use the model manager! |
133
|
|
|
self.component.through.objects.filter( |
134
|
|
|
case=self.pk, component=component.pk |
135
|
|
|
).delete() |
136
|
|
|
|
137
|
|
|
def remove_tag(self, tag): |
138
|
|
|
self.tag.through.objects.filter(case=self.pk, tag=tag.pk).delete() |
139
|
|
|
|
140
|
|
|
def _get_absolute_url(self, request=None): |
141
|
|
|
return reverse( |
142
|
|
|
"testcases-get", |
143
|
|
|
args=[ |
144
|
|
|
self.pk, |
145
|
|
|
], |
146
|
|
|
) |
147
|
|
|
|
148
|
|
|
def get_absolute_url(self): |
149
|
|
|
return self._get_absolute_url() |
150
|
|
|
|
151
|
|
|
def _get_email_conf(self): |
152
|
|
|
try: |
153
|
|
|
# note: this is the reverse_name of a 1-to-1 field |
154
|
|
|
return self.email_settings # pylint: disable=no-member |
155
|
|
|
except ObjectDoesNotExist: |
156
|
|
|
return TestCaseEmailSettings.objects.create(case=self) |
157
|
|
|
|
158
|
|
|
emailing = property(_get_email_conf) |
159
|
|
|
|
160
|
|
|
def clone(self, new_author, test_plans): |
161
|
|
|
new_tc = self.__class__.objects.create( |
162
|
|
|
is_automated=self.is_automated, |
163
|
|
|
script=self.script, |
164
|
|
|
arguments=self.arguments, |
165
|
|
|
extra_link=self.extra_link, |
166
|
|
|
summary=self.summary, |
167
|
|
|
requirement=self.requirement, |
168
|
|
|
case_status=TestCaseStatus.objects.filter(is_confirmed=False).first(), |
169
|
|
|
category=self.category, |
170
|
|
|
priority=self.priority, |
171
|
|
|
notes=self.notes, |
172
|
|
|
text=self.text, |
173
|
|
|
author=new_author, |
174
|
|
|
default_tester=self.default_tester, |
175
|
|
|
) |
176
|
|
|
|
177
|
|
|
# apply tags as well |
178
|
|
|
for tag in self.tag.all(): |
179
|
|
|
new_tc.add_tag(tag) |
180
|
|
|
|
181
|
|
|
for plan in test_plans: |
182
|
|
|
plan.add_case(new_tc) |
183
|
|
|
|
184
|
|
|
# clone TC category b/c we may be cloning a 'linked' |
185
|
|
|
# TC which has a different Product that doesn't have the |
186
|
|
|
# same categories yet |
187
|
|
|
try: |
188
|
|
|
tc_category = plan.product.category.get(name=self.category.name) |
189
|
|
|
except ObjectDoesNotExist: |
190
|
|
|
tc_category = plan.product.category.create( |
191
|
|
|
name=self.category.name, |
192
|
|
|
description=self.category.description, |
193
|
|
|
) |
194
|
|
|
new_tc.category = tc_category |
195
|
|
|
new_tc.save() |
196
|
|
|
|
197
|
|
|
# clone TC components b/c we may be cloning a 'linked' |
198
|
|
|
# TC which has a different Product that doesn't have the |
199
|
|
|
# same components yet |
200
|
|
|
for component in self.component.all(): |
201
|
|
|
try: |
202
|
|
|
new_component = plan.product.component.get(name=component.name) |
203
|
|
|
except ObjectDoesNotExist: |
204
|
|
|
new_component = plan.product.component.create( |
205
|
|
|
name=component.name, |
206
|
|
|
initial_owner=new_author, |
207
|
|
|
description=component.description, |
208
|
|
|
) |
209
|
|
|
new_tc.add_component(new_component) |
210
|
|
|
|
211
|
|
|
return new_tc |
212
|
|
|
|
213
|
|
|
|
214
|
|
|
class TestCasePlan(models.Model): |
215
|
|
|
plan = models.ForeignKey("testplans.TestPlan", on_delete=models.CASCADE) |
216
|
|
|
case = models.ForeignKey(TestCase, on_delete=models.CASCADE) |
217
|
|
|
sortkey = models.IntegerField(null=True, blank=True) |
218
|
|
|
|
219
|
|
|
class Meta: |
220
|
|
|
unique_together = ("plan", "case") |
221
|
|
|
|
222
|
|
|
|
223
|
|
|
class TestCaseComponent(models.Model): |
224
|
|
|
case = models.ForeignKey(TestCase, on_delete=models.CASCADE) |
225
|
|
|
component = models.ForeignKey("management.Component", on_delete=models.CASCADE) |
226
|
|
|
|
227
|
|
|
|
228
|
|
|
class TestCaseTag(models.Model): |
229
|
|
|
tag = models.ForeignKey("management.Tag", on_delete=models.CASCADE) |
230
|
|
|
case = models.ForeignKey(TestCase, on_delete=models.CASCADE) |
231
|
|
|
|
232
|
|
|
|
233
|
|
|
class BugSystem(models.Model, UrlMixin): |
234
|
|
|
""" |
235
|
|
|
This model describes a bug tracking system used in |
236
|
|
|
Kiwi TCMS. Fields below can be configured via |
237
|
|
|
the admin interface and their meaning is: |
238
|
|
|
|
239
|
|
|
#. **name:** a visual name for this bug tracker, e.g. `Kiwi TCMS GitHub`; |
240
|
|
|
#. **tracker_type:** a select menu to specify what kind of external |
241
|
|
|
system we interface with, e.g. Bugzilla, JIRA, others; |
242
|
|
|
The available options for this field are automatically populated |
243
|
|
|
by Kiwi TCMS; |
244
|
|
|
|
245
|
|
|
.. warning:: |
246
|
|
|
|
247
|
|
|
Once this field is set it can't be reset to ``NULL``. Although |
248
|
|
|
Kiwi TCMS takes care to handle misconfigurations we advise you to |
249
|
|
|
configure your API credentials properly! |
250
|
|
|
|
251
|
|
|
#. **base_url:** base URL of this bug tracker. |
252
|
|
|
|
253
|
|
|
.. warning:: |
254
|
|
|
|
255
|
|
|
If this field is left empty funtionality that depends on it will be disabled! |
256
|
|
|
|
257
|
|
|
#. **api_url, api_username, api_password:** configuration for an internal RPC object |
258
|
|
|
that communicate to the issue tracking system when necessary. Depending on the |
259
|
|
|
actual type of IT we're interfacing with some of these values may not be necessary. |
260
|
|
|
Refer to :mod:`tcms.issuetracker.types` for more information! |
261
|
|
|
|
262
|
|
|
.. warning:: |
263
|
|
|
|
264
|
|
|
This is saved as plain-text in the database because it needs to be passed |
265
|
|
|
to the internal RPC object! |
266
|
|
|
""" |
267
|
|
|
|
268
|
|
|
name = models.CharField(max_length=255, unique=True) |
269
|
|
|
tracker_type = models.CharField( # pylint:disable=form-field-help-text-used |
270
|
|
|
max_length=128, |
271
|
|
|
verbose_name="Type", |
272
|
|
|
help_text="This determines how Kiwi TCMS integrates with the IT system", |
273
|
|
|
default="IssueTrackerType", |
274
|
|
|
) |
275
|
|
|
|
276
|
|
|
base_url = models.CharField( # pylint:disable=form-field-help-text-used |
277
|
|
|
max_length=1024, |
278
|
|
|
null=True, |
279
|
|
|
blank=True, |
280
|
|
|
verbose_name="Base URL", |
281
|
|
|
help_text="""Base URL, for example <strong>https://bugzilla.example.com</strong>! |
282
|
|
|
Leave empty to disable! |
283
|
|
|
""", |
284
|
|
|
) |
285
|
|
|
|
286
|
|
|
api_url = models.CharField( # pylint:disable=form-field-help-text-used |
287
|
|
|
max_length=1024, |
288
|
|
|
null=True, |
289
|
|
|
blank=True, |
290
|
|
|
verbose_name="API URL", |
291
|
|
|
help_text="This is the URL to which API requests will be sent. Leave empty to disable!", |
292
|
|
|
) |
293
|
|
|
|
294
|
|
|
api_username = models.CharField( |
295
|
|
|
max_length=256, null=True, blank=True, verbose_name="API username" |
296
|
|
|
) |
297
|
|
|
|
298
|
|
|
api_password = models.CharField( |
299
|
|
|
max_length=256, null=True, blank=True, verbose_name="API password or token" |
300
|
|
|
) |
301
|
|
|
|
302
|
|
|
class Meta: |
303
|
|
|
verbose_name = "Bug tracker" |
304
|
|
|
verbose_name_plural = "Bug trackers" |
305
|
|
|
|
306
|
|
|
def __str__(self): |
307
|
|
|
return self.name |
308
|
|
|
|
309
|
|
|
|
310
|
|
|
class TestCaseEmailSettings(models.Model): |
311
|
|
|
case = models.OneToOneField( |
312
|
|
|
TestCase, related_name="email_settings", on_delete=models.CASCADE |
313
|
|
|
) |
314
|
|
|
notify_on_case_update = models.BooleanField(default=True) |
315
|
|
|
notify_on_case_delete = models.BooleanField(default=True) |
316
|
|
|
auto_to_case_author = models.BooleanField(default=True) |
317
|
|
|
auto_to_case_tester = models.BooleanField(default=True) |
318
|
|
|
auto_to_run_manager = models.BooleanField(default=True) |
319
|
|
|
auto_to_run_tester = models.BooleanField(default=True) |
320
|
|
|
auto_to_execution_assignee = models.BooleanField(default=True) |
321
|
|
|
|
322
|
|
|
cc_list = models.TextField(default="") |
323
|
|
|
|
324
|
|
|
def add_cc(self, email_addrs): |
325
|
|
|
"""Add email addresses to CC list |
326
|
|
|
|
327
|
|
|
Arguments: |
328
|
|
|
- email_addrs: str or list, holding one or more email addresses |
329
|
|
|
""" |
330
|
|
|
|
331
|
|
|
emailaddr_list = self.get_cc_list() |
332
|
|
|
if not isinstance(email_addrs, list): |
333
|
|
|
email_addrs = [email_addrs] |
334
|
|
|
|
335
|
|
|
# skip addresses already in the list |
336
|
|
|
for address in email_addrs: |
337
|
|
|
if address not in emailaddr_list: |
338
|
|
|
emailaddr_list.append(address) |
339
|
|
|
|
340
|
|
|
self.cc_list = MultipleEmailField.delimiter.join(emailaddr_list) |
341
|
|
|
self.save() |
342
|
|
|
|
343
|
|
|
def get_cc_list(self): |
344
|
|
|
""" Return the whole CC list """ |
345
|
|
|
if not self.cc_list: |
346
|
|
|
return [] |
347
|
|
|
return self.cc_list.split(MultipleEmailField.delimiter) |
348
|
|
|
|
349
|
|
|
def remove_cc(self, email_addrs): |
350
|
|
|
"""Remove one or more email addresses from EmailSettings' CC list |
351
|
|
|
|
352
|
|
|
If any email_addr is unknown, remove_cc will keep quiet. |
353
|
|
|
|
354
|
|
|
Arguments: |
355
|
|
|
- email_addrs: str or list, holding one or more email addresses |
356
|
|
|
""" |
357
|
|
|
emailaddr_list = self.get_cc_list() |
358
|
|
|
if not isinstance(email_addrs, list): |
359
|
|
|
email_addrs = [email_addrs] |
360
|
|
|
for address in email_addrs: |
361
|
|
|
if address in emailaddr_list: |
362
|
|
|
emailaddr_list.remove(address) |
363
|
|
|
|
364
|
|
|
self.cc_list = MultipleEmailField.delimiter.join(emailaddr_list) |
365
|
|
|
self.save() |
366
|
|
|
|