1
|
|
|
# -*- coding: utf-8 -*- |
2
|
|
|
from django.conf import settings |
3
|
|
|
from django.urls import reverse |
4
|
|
|
from django.db import models |
5
|
|
|
from django.utils.translation import override |
6
|
|
|
from django.db.models import ObjectDoesNotExist |
7
|
|
|
|
8
|
|
|
import vinaigrette |
9
|
|
|
|
10
|
|
|
from tcms.core.models import TCMSActionModel |
11
|
|
|
from tcms.core.history import KiwiHistoricalRecords |
12
|
|
|
from tcms.testcases.fields import MultipleEmailField |
13
|
|
|
|
14
|
|
|
|
15
|
|
|
class TestCaseStatus(TCMSActionModel): |
16
|
|
|
id = models.AutoField( |
17
|
|
|
db_column='case_status_id', max_length=6, primary_key=True |
18
|
|
|
) |
19
|
|
|
# FIXME: if name has unique value for each status, give unique constraint |
20
|
|
|
# to this field. Otherwise, all SQL queries filtering upon this |
21
|
|
|
# field will cost much time in the database side. |
22
|
|
|
name = models.CharField(max_length=255) |
23
|
|
|
description = models.TextField(null=True, blank=True) |
24
|
|
|
|
25
|
|
|
class Meta: |
26
|
|
|
verbose_name = "Test case status" |
27
|
|
|
verbose_name_plural = "Test case statuses" |
28
|
|
|
|
29
|
|
|
def __str__(self): |
30
|
|
|
return self.name |
31
|
|
|
|
32
|
|
|
@classmethod |
33
|
|
|
def get_proposed(cls): |
34
|
|
|
return cls.objects.get(name='PROPOSED') |
35
|
|
|
|
36
|
|
|
@classmethod |
37
|
|
|
def get_confirmed(cls): |
38
|
|
|
return cls.objects.get(name='CONFIRMED') |
39
|
|
|
|
40
|
|
|
@classmethod |
41
|
|
|
def string_to_instance(cls, name): |
42
|
|
|
return cls.objects.get(name=name) |
43
|
|
|
|
44
|
|
|
def is_confirmed(self): |
45
|
|
|
with override('en'): |
46
|
|
|
return self.name == 'CONFIRMED' |
47
|
|
|
|
48
|
|
|
|
49
|
|
|
# register model for DB translations |
50
|
|
|
vinaigrette.register(TestCaseStatus, ['name']) |
51
|
|
|
|
52
|
|
|
|
53
|
|
|
class Category(TCMSActionModel): |
54
|
|
|
id = models.AutoField(db_column='category_id', primary_key=True) |
55
|
|
|
name = models.CharField(max_length=255) |
56
|
|
|
product = models.ForeignKey('management.Product', related_name="category", |
57
|
|
|
on_delete=models.CASCADE) |
58
|
|
|
description = models.TextField(blank=True) |
59
|
|
|
|
60
|
|
|
class Meta: |
61
|
|
|
verbose_name_plural = u'test case categories' |
62
|
|
|
unique_together = ('product', 'name') |
63
|
|
|
|
64
|
|
|
def __str__(self): |
65
|
|
|
return self.name |
66
|
|
|
|
67
|
|
|
|
68
|
|
|
class TestCase(TCMSActionModel): |
69
|
|
|
history = KiwiHistoricalRecords() |
70
|
|
|
|
71
|
|
|
case_id = models.AutoField(primary_key=True) |
72
|
|
|
create_date = models.DateTimeField(db_column='creation_date', auto_now_add=True) |
73
|
|
|
is_automated = models.BooleanField(default=False) |
74
|
|
|
script = models.TextField(blank=True, null=True) |
75
|
|
|
arguments = models.TextField(blank=True, null=True) |
76
|
|
|
extra_link = models.CharField(max_length=1024, default=None, blank=True, null=True) |
77
|
|
|
summary = models.CharField(max_length=255) |
78
|
|
|
requirement = models.CharField(max_length=255, blank=True, null=True) |
79
|
|
|
notes = models.TextField(blank=True, null=True) |
80
|
|
|
text = models.TextField(blank=True) |
81
|
|
|
|
82
|
|
|
case_status = models.ForeignKey(TestCaseStatus, on_delete=models.CASCADE) |
83
|
|
|
category = models.ForeignKey(Category, related_name='category_case', |
84
|
|
|
on_delete=models.CASCADE) |
85
|
|
|
priority = models.ForeignKey('management.Priority', related_name='priority_case', |
86
|
|
|
on_delete=models.CASCADE) |
87
|
|
|
author = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='cases_as_author', |
88
|
|
|
on_delete=models.CASCADE) |
89
|
|
|
default_tester = models.ForeignKey(settings.AUTH_USER_MODEL, |
90
|
|
|
related_name='cases_as_default_tester', |
91
|
|
|
blank=True, |
92
|
|
|
null=True, |
93
|
|
|
on_delete=models.CASCADE) |
94
|
|
|
reviewer = models.ForeignKey(settings.AUTH_USER_MODEL, |
95
|
|
|
related_name='cases_as_reviewer', |
96
|
|
|
null=True, |
97
|
|
|
on_delete=models.CASCADE) |
98
|
|
|
|
99
|
|
|
# FIXME: related_name should be cases instead of case. But now keep it |
100
|
|
|
# named case due to historical reason. |
101
|
|
|
plan = models.ManyToManyField('testplans.TestPlan', related_name='case', |
102
|
|
|
through='testcases.TestCasePlan') |
103
|
|
|
|
104
|
|
|
component = models.ManyToManyField('management.Component', related_name='cases', |
105
|
|
|
through='testcases.TestCaseComponent') |
106
|
|
|
|
107
|
|
|
tag = models.ManyToManyField('management.Tag', related_name='case', |
108
|
|
|
through='testcases.TestCaseTag') |
109
|
|
|
|
110
|
|
|
def __str__(self): |
111
|
|
|
return self.summary |
112
|
|
|
|
113
|
|
|
@classmethod |
114
|
|
|
def to_xmlrpc(cls, query=None): |
115
|
|
|
from tcms.xmlrpc.serializer import TestCaseXMLRPCSerializer |
116
|
|
|
from tcms.xmlrpc.utils import distinct_filter |
117
|
|
|
|
118
|
|
|
_query = query or {} |
119
|
|
|
qs = distinct_filter(TestCase, _query).order_by('pk') |
120
|
|
|
serializer = TestCaseXMLRPCSerializer(model_class=cls, queryset=qs) |
121
|
|
|
return serializer.serialize_queryset() |
122
|
|
|
|
123
|
|
|
# todo: does this check permissions ??? |
124
|
|
|
@classmethod |
125
|
|
|
def create(cls, author, values): |
126
|
|
|
""" |
127
|
|
|
Create the case element based on models/forms. |
128
|
|
|
""" |
129
|
|
|
case = cls.objects.create( |
130
|
|
|
author=author, |
131
|
|
|
is_automated=values['is_automated'], |
132
|
|
|
# sortkey = values['sortkey'], |
133
|
|
|
script=values['script'], |
134
|
|
|
arguments=values['arguments'], |
135
|
|
|
extra_link=values['extra_link'], |
136
|
|
|
summary=values['summary'], |
137
|
|
|
requirement=values['requirement'], |
138
|
|
|
case_status=values['case_status'], |
139
|
|
|
category=values['category'], |
140
|
|
|
priority=values['priority'], |
141
|
|
|
default_tester=values['default_tester'], |
142
|
|
|
notes=values['notes'], |
143
|
|
|
text=values['text'], |
144
|
|
|
) |
145
|
|
|
|
146
|
|
|
# todo: should use add_tag |
147
|
|
|
tags = values.get('tag') |
148
|
|
|
if tags: |
149
|
|
|
map(case.add_tag, tags) |
150
|
|
|
return case |
151
|
|
|
|
152
|
|
|
@classmethod |
153
|
|
|
def list(cls, query, plan=None): |
154
|
|
|
"""List the cases with request""" |
155
|
|
|
from django.db.models import Q |
156
|
|
|
|
157
|
|
|
if not plan: |
158
|
|
|
queryset = cls.objects |
159
|
|
|
else: |
160
|
|
|
queryset = cls.objects.filter(plan=plan) |
161
|
|
|
|
162
|
|
|
if query.get('case_id_set'): |
163
|
|
|
queryset = queryset.filter(pk__in=query['case_id_set']) |
164
|
|
|
|
165
|
|
|
if query.get('search'): |
166
|
|
|
queryset = queryset.filter( |
167
|
|
|
Q(pk__icontains=query['search']) | |
168
|
|
|
Q(summary__icontains=query['search']) | |
169
|
|
|
Q(author__email__startswith=query['search']) |
170
|
|
|
) |
171
|
|
|
|
172
|
|
|
if query.get('summary'): |
173
|
|
|
queryset = queryset.filter(Q(summary__icontains=query['summary'])) |
174
|
|
|
|
175
|
|
|
if query.get('author'): |
176
|
|
|
queryset = queryset.filter( |
177
|
|
|
Q(author__first_name__startswith=query['author']) | |
178
|
|
|
Q(author__last_name__startswith=query['author']) | |
179
|
|
|
Q(author__username__icontains=query['author']) | |
180
|
|
|
Q(author__email__startswith=query['author']) |
181
|
|
|
) |
182
|
|
|
|
183
|
|
|
if query.get('default_tester'): |
184
|
|
|
queryset = queryset.filter( |
185
|
|
|
Q(default_tester__first_name__startswith=query[ |
186
|
|
|
'default_tester']) | |
187
|
|
|
Q(default_tester__last_name__startswith=query[ |
188
|
|
|
'default_tester']) | |
189
|
|
|
Q(default_tester__username__icontains=query[ |
190
|
|
|
'default_tester']) | |
191
|
|
|
Q(default_tester__email__startswith=query[ |
192
|
|
|
'default_tester']) |
193
|
|
|
) |
194
|
|
|
|
195
|
|
|
if query.get('tag__name__in'): |
196
|
|
|
queryset = queryset.filter(tag__name__in=query['tag__name__in']) |
197
|
|
|
|
198
|
|
|
if query.get('category'): |
199
|
|
|
queryset = queryset.filter(category__name=query['category'].name) |
200
|
|
|
|
201
|
|
|
if query.get('priority'): |
202
|
|
|
queryset = queryset.filter(priority__in=query['priority']) |
203
|
|
|
|
204
|
|
|
if query.get('case_status'): |
205
|
|
|
queryset = queryset.filter(case_status__in=query['case_status']) |
206
|
|
|
|
207
|
|
|
# If plan exists, remove leading and trailing whitespace from it. |
208
|
|
|
plan_str = query.get('plan', '').strip() |
209
|
|
|
if plan_str: |
210
|
|
|
try: |
211
|
|
|
# Is it an integer? If so treat as a plan_id: |
212
|
|
|
plan_id = int(plan_str) |
213
|
|
|
queryset = queryset.filter(plan__plan_id=plan_id) |
214
|
|
|
except ValueError: |
215
|
|
|
# Not an integer - treat plan_str as a plan name: |
216
|
|
|
queryset = queryset.filter(plan__name__icontains=plan_str) |
217
|
|
|
del plan_str |
218
|
|
|
|
219
|
|
|
if query.get('product'): |
220
|
|
|
queryset = queryset.filter(category__product=query['product']) |
221
|
|
|
|
222
|
|
|
if query.get('component'): |
223
|
|
|
queryset = queryset.filter(component=query['component']) |
224
|
|
|
|
225
|
|
|
if query.get('bug_id'): |
226
|
|
|
queryset = queryset.filter(case_bug__bug_id__in=query['bug_id']) |
227
|
|
|
|
228
|
|
|
if query.get('is_automated'): |
229
|
|
|
queryset = queryset.filter(is_automated=query['is_automated']) |
230
|
|
|
|
231
|
|
|
return queryset.distinct() |
232
|
|
|
|
233
|
|
|
def add_component(self, component): |
234
|
|
|
return TestCaseComponent.objects.get_or_create(case=self, component=component) |
235
|
|
|
|
236
|
|
|
def add_tag(self, tag): |
237
|
|
|
return TestCaseTag.objects.get_or_create(case=self, tag=tag) |
238
|
|
|
|
239
|
|
|
def get_previous_and_next(self, pk_list): |
240
|
|
|
current_idx = pk_list.index(self.pk) |
241
|
|
|
prev = TestCase.objects.get(pk=pk_list[current_idx - 1]) |
242
|
|
|
try: |
243
|
|
|
_next = TestCase.objects.get(pk=pk_list[current_idx + 1]) |
244
|
|
|
except IndexError: |
245
|
|
|
_next = TestCase.objects.get(pk=pk_list[0]) |
246
|
|
|
|
247
|
|
|
return (prev, _next) |
248
|
|
|
|
249
|
|
|
def get_text_with_version(self, case_text_version=None): |
250
|
|
|
if case_text_version: |
251
|
|
|
try: |
252
|
|
|
return self.history.get(history_id=case_text_version).text |
253
|
|
|
except ObjectDoesNotExist: |
254
|
|
|
return self.text |
255
|
|
|
|
256
|
|
|
return self.text |
257
|
|
|
|
258
|
|
|
def remove_component(self, component): |
259
|
|
|
# note: cannot use self.component.remove(component) on a ManyToManyField |
260
|
|
|
# which specifies an intermediary model so we use the model manager! |
261
|
|
|
self.component.through.objects.filter(case=self.pk, component=component.pk).delete() |
262
|
|
|
|
263
|
|
|
def remove_tag(self, tag): |
264
|
|
|
self.tag.through.objects.filter(case=self.pk, tag=tag.pk).delete() |
265
|
|
|
|
266
|
|
|
def _get_absolute_url(self, request=None): |
267
|
|
|
return reverse('testcases-get', args=[self.pk, ]) |
268
|
|
|
|
269
|
|
|
def _get_email_conf(self): |
270
|
|
|
try: |
271
|
|
|
return self.email_settings |
272
|
|
|
except ObjectDoesNotExist: |
273
|
|
|
return TestCaseEmailSettings.objects.create(case=self) |
274
|
|
|
|
275
|
|
|
emailing = property(_get_email_conf) |
276
|
|
|
|
277
|
|
|
|
278
|
|
|
class TestCasePlan(models.Model): |
279
|
|
|
plan = models.ForeignKey('testplans.TestPlan', on_delete=models.CASCADE) |
280
|
|
|
case = models.ForeignKey(TestCase, on_delete=models.CASCADE) |
281
|
|
|
sortkey = models.IntegerField(null=True, blank=True) |
282
|
|
|
|
283
|
|
|
# TODO: create FOREIGN KEY constraint on plan_id and case_id individually |
284
|
|
|
# in database. |
285
|
|
|
|
286
|
|
|
class Meta: |
287
|
|
|
unique_together = ('plan', 'case') |
288
|
|
|
|
289
|
|
|
|
290
|
|
|
class TestCaseComponent(models.Model): |
291
|
|
|
case = models.ForeignKey(TestCase, on_delete=models.CASCADE) # case_id |
292
|
|
|
component = models.ForeignKey('management.Component', on_delete=models.CASCADE) # component_id |
293
|
|
|
|
294
|
|
|
|
295
|
|
|
class TestCaseTag(models.Model): |
296
|
|
|
tag = models.ForeignKey('management.Tag', on_delete=models.CASCADE) |
297
|
|
|
case = models.ForeignKey(TestCase, on_delete=models.CASCADE) |
298
|
|
|
|
299
|
|
|
|
300
|
|
|
class BugSystem(TCMSActionModel): |
301
|
|
|
""" |
302
|
|
|
This model describes a bug tracking system used in |
303
|
|
|
Kiwi TCMS. Fields below can be configured via |
304
|
|
|
the admin interface and their meaning is: |
305
|
|
|
|
306
|
|
|
#. **name:** a visual name for this bug tracker, e.g. `Kiwi TCMS GitHub`; |
307
|
|
|
#. **tracker_type:** a select menu to specify what kind of external |
308
|
|
|
system we interface with, e.g. Bugzilla, JIRA, others; |
309
|
|
|
The available options for this field are automatically populated |
310
|
|
|
by Kiwi TCMS; |
311
|
|
|
|
312
|
|
|
.. warning:: |
313
|
|
|
|
314
|
|
|
Once this field is set it can't be reset to ``NULL``. Although |
315
|
|
|
Kiwi TCMS takes care to handle misconfigurations we advise you to |
316
|
|
|
configure your API credentials properly! |
317
|
|
|
|
318
|
|
|
#. **base_url:** base URL of this bug tracker. |
319
|
|
|
|
320
|
|
|
.. warning:: |
321
|
|
|
|
322
|
|
|
If this field is left empty funtionality that depends on it will be disabled! |
323
|
|
|
|
324
|
|
|
#. **api_url, api_username, api_password:** configuration for an internal RPC object |
325
|
|
|
that communicate to the issue tracking system when necessary. Depending on the |
326
|
|
|
actual type of IT we're interfacing with some of these values may not be necessary. |
327
|
|
|
Refer to :mod:`tcms.issuetracker.types` for more information! |
328
|
|
|
|
329
|
|
|
.. warning:: |
330
|
|
|
|
331
|
|
|
This is saved as plain-text in the database because it needs to be passed |
332
|
|
|
to the internal RPC object! |
333
|
|
|
""" |
334
|
|
|
name = models.CharField(max_length=255, unique=True) |
335
|
|
|
tracker_type = models.CharField( |
336
|
|
|
max_length=128, |
337
|
|
|
verbose_name='Type', |
338
|
|
|
help_text='This determines how Kiwi TCMS integrates with the IT system', |
339
|
|
|
default='IssueTrackerType', |
340
|
|
|
) |
341
|
|
|
|
342
|
|
|
base_url = models.CharField( |
343
|
|
|
max_length=1024, |
344
|
|
|
null=True, |
345
|
|
|
blank=True, |
346
|
|
|
verbose_name='Base URL', |
347
|
|
|
help_text="""Base URL, for example <strong>https://bugzilla.example.com</strong>! |
348
|
|
|
Leave empty to disable! |
349
|
|
|
""") |
350
|
|
|
|
351
|
|
|
api_url = models.CharField( |
352
|
|
|
max_length=1024, |
353
|
|
|
null=True, |
354
|
|
|
blank=True, |
355
|
|
|
verbose_name='API URL', |
356
|
|
|
help_text='This is the URL to which API requests will be sent. Leave empty to disable!') |
357
|
|
|
|
358
|
|
|
api_username = models.CharField( |
359
|
|
|
max_length=256, |
360
|
|
|
null=True, |
361
|
|
|
blank=True, |
362
|
|
|
verbose_name='API username') |
363
|
|
|
|
364
|
|
|
api_password = models.CharField( |
365
|
|
|
max_length=256, |
366
|
|
|
null=True, |
367
|
|
|
blank=True, |
368
|
|
|
verbose_name='API password or token') |
369
|
|
|
|
370
|
|
|
class Meta: |
371
|
|
|
verbose_name = 'Bug tracker' |
372
|
|
|
verbose_name_plural = 'Bug trackers' |
373
|
|
|
|
374
|
|
|
def __str__(self): |
375
|
|
|
return self.name |
376
|
|
|
|
377
|
|
|
|
378
|
|
|
class TestCaseEmailSettings(models.Model): |
379
|
|
|
case = models.OneToOneField(TestCase, related_name='email_settings', on_delete=models.CASCADE) |
380
|
|
|
notify_on_case_update = models.BooleanField(default=True) |
381
|
|
|
notify_on_case_delete = models.BooleanField(default=True) |
382
|
|
|
auto_to_case_author = models.BooleanField(default=True) |
383
|
|
|
auto_to_case_tester = models.BooleanField(default=True) |
384
|
|
|
auto_to_run_manager = models.BooleanField(default=True) |
385
|
|
|
auto_to_run_tester = models.BooleanField(default=True) |
386
|
|
|
auto_to_case_run_assignee = models.BooleanField(default=True) |
387
|
|
|
|
388
|
|
|
cc_list = models.TextField(default='') |
389
|
|
|
|
390
|
|
|
def add_cc(self, email_addrs): |
391
|
|
|
"""Add email addresses to CC list |
392
|
|
|
|
393
|
|
|
Arguments: |
394
|
|
|
- email_addrs: str or list, holding one or more email addresses |
395
|
|
|
""" |
396
|
|
|
|
397
|
|
|
emailaddr_list = self.get_cc_list() |
398
|
|
|
if not isinstance(email_addrs, list): |
399
|
|
|
email_addrs = [email_addrs] |
400
|
|
|
|
401
|
|
|
# skip addresses already in the list |
402
|
|
|
for address in email_addrs: |
403
|
|
|
if address not in emailaddr_list: |
404
|
|
|
emailaddr_list.append(address) |
405
|
|
|
|
406
|
|
|
self.cc_list = MultipleEmailField.delimiter.join(emailaddr_list) |
407
|
|
|
self.save() |
408
|
|
|
|
409
|
|
|
def get_cc_list(self): |
410
|
|
|
""" Return the whole CC list """ |
411
|
|
|
if not self.cc_list: |
412
|
|
|
return [] |
413
|
|
|
return self.cc_list.split(MultipleEmailField.delimiter) |
414
|
|
|
|
415
|
|
|
def remove_cc(self, email_addrs): |
416
|
|
|
"""Remove one or more email addresses from EmailSettings' CC list |
417
|
|
|
|
418
|
|
|
If any email_addr is unknown, remove_cc will keep quiet. |
419
|
|
|
|
420
|
|
|
Arguments: |
421
|
|
|
- email_addrs: str or list, holding one or more email addresses |
422
|
|
|
""" |
423
|
|
|
emailaddr_list = self.get_cc_list() |
424
|
|
|
if not isinstance(email_addrs, list): |
425
|
|
|
email_addrs = [email_addrs] |
426
|
|
|
for address in email_addrs: |
427
|
|
|
if address in emailaddr_list: |
428
|
|
|
emailaddr_list.remove(address) |
429
|
|
|
|
430
|
|
|
self.cc_list = MultipleEmailField.delimiter.join(emailaddr_list) |
431
|
|
|
self.save() |
432
|
|
|
|