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