firefighter-incident 0.0.13__py3-none-any.whl → 0.0.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- firefighter/_version.py +16 -3
- firefighter/api/serializers.py +17 -8
- firefighter/api/urls.py +8 -1
- firefighter/api/views/_base.py +1 -1
- firefighter/api/views/components.py +5 -5
- firefighter/api/views/incidents.py +9 -9
- firefighter/confluence/signals/incident_updated.py +2 -2
- firefighter/firefighter/settings/components/raid.py +3 -0
- firefighter/incidents/admin.py +24 -24
- firefighter/incidents/enums.py +22 -2
- firefighter/incidents/factories.py +14 -5
- firefighter/incidents/forms/close_incident.py +4 -4
- firefighter/incidents/forms/closure_reason.py +45 -0
- firefighter/incidents/forms/create_incident.py +4 -4
- firefighter/incidents/forms/unified_incident.py +406 -0
- firefighter/incidents/forms/update_status.py +91 -5
- firefighter/incidents/menus.py +2 -2
- firefighter/incidents/migrations/0005_enable_from_p1_to_p5_priority.py +7 -5
- firefighter/incidents/migrations/0009_update_sla.py +7 -5
- firefighter/incidents/migrations/0020_create_incident_category_model.py +64 -0
- firefighter/incidents/migrations/0021_copy_component_data_to_incident_category.py +57 -0
- firefighter/incidents/migrations/0022_add_incident_category_fields.py +34 -0
- firefighter/incidents/migrations/0023_populate_incident_category_references.py +57 -0
- firefighter/incidents/migrations/0024_remove_component_fields_and_model.py +26 -0
- firefighter/incidents/migrations/0025_make_incident_category_required.py +24 -0
- firefighter/incidents/migrations/0026_alter_incidentcategory_options_and_more.py +39 -0
- firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
- firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
- firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
- firefighter/incidents/models/__init__.py +1 -1
- firefighter/incidents/models/group.py +1 -1
- firefighter/incidents/models/incident.py +47 -20
- firefighter/incidents/models/{component.py → incident_category.py} +30 -29
- firefighter/incidents/models/incident_update.py +3 -3
- firefighter/incidents/static/css/main.min.css +1 -1
- firefighter/incidents/tables.py +9 -9
- firefighter/incidents/templates/layouts/partials/incident_card.html +1 -1
- firefighter/incidents/templates/layouts/partials/incident_timeline.html +2 -2
- firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
- firefighter/incidents/templates/pages/{component_detail.html → incident_category_detail.html} +13 -13
- firefighter/incidents/templates/pages/{component_list.html → incident_category_list.html} +2 -2
- firefighter/incidents/templates/pages/incident_detail.html +3 -3
- firefighter/incidents/urls.py +6 -6
- firefighter/incidents/views/components/details.py +9 -9
- firefighter/incidents/views/components/list.py +9 -9
- firefighter/incidents/views/reports.py +5 -5
- firefighter/incidents/views/users/details.py +2 -2
- firefighter/incidents/views/views.py +7 -7
- firefighter/jira_app/client.py +1 -1
- firefighter/logging/custom_json_formatter.py +2 -1
- firefighter/pagerduty/tasks/trigger_oncall.py +1 -1
- firefighter/raid/admin.py +0 -11
- firefighter/raid/apps.py +9 -26
- firefighter/raid/client.py +5 -5
- firefighter/raid/forms.py +84 -213
- firefighter/raid/migrations/0003_delete_raidarea.py +16 -0
- firefighter/raid/models.py +2 -21
- firefighter/raid/serializers.py +5 -4
- firefighter/raid/service.py +29 -27
- firefighter/raid/signals/incident_created.py +42 -15
- firefighter/raid/signals/incident_updated.py +3 -2
- firefighter/raid/utils.py +1 -1
- firefighter/raid/views/__init__.py +1 -1
- firefighter/slack/admin.py +8 -8
- firefighter/slack/management/commands/switch_test_users.py +272 -0
- firefighter/slack/messages/slack_messages.py +24 -9
- firefighter/slack/migrations/0005_add_incident_categories_fields.py +33 -0
- firefighter/slack/migrations/0006_copy_components_to_incident_categories.py +57 -0
- firefighter/slack/migrations/0007_remove_components_fields.py +22 -0
- firefighter/slack/migrations/0008_alter_conversation_incident_categories_and_more.py +33 -0
- firefighter/slack/models/conversation.py +3 -3
- firefighter/slack/models/incident_channel.py +1 -1
- firefighter/slack/models/user.py +1 -1
- firefighter/slack/models/user_group.py +3 -3
- firefighter/slack/rules.py +2 -2
- firefighter/slack/signals/create_incident_conversation.py +6 -0
- firefighter/slack/signals/get_users.py +2 -2
- firefighter/slack/signals/incident_updated.py +8 -2
- firefighter/slack/utils.py +2 -2
- firefighter/slack/views/events/home.py +2 -2
- firefighter/slack/views/modals/__init__.py +4 -0
- firefighter/slack/views/modals/base_modal/form_utils.py +78 -0
- firefighter/slack/views/modals/close.py +18 -5
- firefighter/slack/views/modals/closure_reason.py +193 -0
- firefighter/slack/views/modals/open.py +83 -12
- firefighter/slack/views/modals/opening/check_current_incidents.py +2 -2
- firefighter/slack/views/modals/opening/details/unified.py +203 -0
- firefighter/slack/views/modals/opening/select_impact.py +5 -2
- firefighter/slack/views/modals/opening/set_details.py +3 -2
- firefighter/slack/views/modals/postmortem.py +10 -2
- firefighter/slack/views/modals/update_status.py +32 -6
- firefighter/slack/views/modals/utils.py +51 -0
- firefighter_fixtures/incidents/{components.json → incident_categories.json} +52 -52
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +2 -2
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +133 -88
- firefighter_tests/conftest.py +4 -5
- firefighter_tests/test_api/test_api_landbot.py +1 -1
- firefighter_tests/test_firefighter/test_sso.py +146 -0
- firefighter_tests/test_incidents/test_enums.py +100 -0
- firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
- firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
- firefighter_tests/test_incidents/test_forms/test_form_utils.py +15 -15
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
- firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
- firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
- firefighter_tests/test_incidents/test_incident_urls.py +3 -3
- firefighter_tests/test_incidents/test_models/test_incident_category.py +165 -0
- firefighter_tests/test_incidents/test_models/test_incident_model.py +70 -2
- firefighter_tests/test_raid/conftest.py +154 -0
- firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
- firefighter_tests/test_raid/test_priority_mapping.py +267 -0
- firefighter_tests/test_raid/test_raid_client.py +580 -0
- firefighter_tests/test_raid/test_raid_forms.py +552 -0
- firefighter_tests/test_raid/test_raid_models.py +185 -0
- firefighter_tests/test_raid/test_raid_serializers.py +507 -0
- firefighter_tests/test_raid/test_raid_service.py +442 -0
- firefighter_tests/test_raid/test_raid_signals.py +187 -0
- firefighter_tests/test_raid/test_raid_views.py +196 -0
- firefighter_tests/test_slack/messages/__init__.py +0 -0
- firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
- firefighter_tests/test_slack/views/modals/conftest.py +140 -0
- firefighter_tests/test_slack/views/modals/test_close.py +71 -9
- firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
- firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
- firefighter_tests/test_slack/views/modals/test_open.py +146 -2
- firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
- firefighter_tests/test_slack/views/modals/test_update_status.py +331 -7
- firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
- firefighter/raid/views/open_normal.py +0 -139
- firefighter/slack/views/modals/opening/details/critical.py +0 -88
- firefighter_fixtures/raid/area.json +0 -1
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Test the status workflow logic for update status form."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
from django.test import TestCase
|
|
7
|
+
|
|
8
|
+
from firefighter.incidents.enums import IncidentStatus
|
|
9
|
+
from firefighter.incidents.factories import IncidentFactory
|
|
10
|
+
from firefighter.incidents.forms.update_status import UpdateStatusForm
|
|
11
|
+
from firefighter.incidents.models import Environment, Priority
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.django_db
|
|
15
|
+
class TestUpdateStatusWorkflow(TestCase):
|
|
16
|
+
"""Test that status choices respect workflow rules."""
|
|
17
|
+
|
|
18
|
+
def test_p1_p2_cannot_skip_postmortem(self):
|
|
19
|
+
"""P1/P2 incidents cannot go directly from Mitigated to Closed."""
|
|
20
|
+
|
|
21
|
+
# Get existing P1 or P2 priority that needs post-mortem
|
|
22
|
+
p1_priority = Priority.objects.filter(
|
|
23
|
+
value=1, needs_postmortem=True
|
|
24
|
+
).first()
|
|
25
|
+
|
|
26
|
+
if not p1_priority:
|
|
27
|
+
# If P1 doesn't exist, try P2
|
|
28
|
+
p1_priority = Priority.objects.filter(
|
|
29
|
+
value=2, needs_postmortem=True
|
|
30
|
+
).first()
|
|
31
|
+
|
|
32
|
+
if not p1_priority:
|
|
33
|
+
# Skip test if no priority needing postmortem exists
|
|
34
|
+
pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
|
|
35
|
+
|
|
36
|
+
# Get PRD environment (required for needs_postmortem to be True)
|
|
37
|
+
prd_env = Environment.objects.filter(value="PRD").first()
|
|
38
|
+
if not prd_env:
|
|
39
|
+
pytest.skip("No PRD environment found in database")
|
|
40
|
+
|
|
41
|
+
# Create an incident with P1/P2 priority in Mitigated status with PRD env
|
|
42
|
+
incident = IncidentFactory.create(
|
|
43
|
+
priority=p1_priority,
|
|
44
|
+
environment=prd_env,
|
|
45
|
+
_status=IncidentStatus.MITIGATED,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Note: incident.needs_postmortem also requires firefighter.confluence to be installed
|
|
49
|
+
# For testing, we'll check the underlying conditions directly
|
|
50
|
+
if apps.is_installed("firefighter.confluence"):
|
|
51
|
+
assert incident.needs_postmortem, "Incident should need postmortem (P1/P2 + PRD)"
|
|
52
|
+
|
|
53
|
+
# Create form with the incident
|
|
54
|
+
form = UpdateStatusForm(incident=incident)
|
|
55
|
+
|
|
56
|
+
# Check that CLOSED is not in the choices
|
|
57
|
+
status_choices = dict(form.fields["status"].choices)
|
|
58
|
+
assert IncidentStatus.CLOSED not in status_choices
|
|
59
|
+
assert IncidentStatus.POST_MORTEM in status_choices
|
|
60
|
+
|
|
61
|
+
def test_p1_p2_can_close_from_postmortem(self):
|
|
62
|
+
"""P1/P2 incidents can go from Post-mortem to Closed."""
|
|
63
|
+
|
|
64
|
+
# Get existing P1 or P2 priority that needs post-mortem
|
|
65
|
+
p2_priority = Priority.objects.filter(
|
|
66
|
+
value__in=[1, 2], needs_postmortem=True
|
|
67
|
+
).first()
|
|
68
|
+
|
|
69
|
+
if not p2_priority:
|
|
70
|
+
pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
|
|
71
|
+
|
|
72
|
+
# Get PRD environment
|
|
73
|
+
prd_env = Environment.objects.filter(value="PRD").first()
|
|
74
|
+
if not prd_env:
|
|
75
|
+
pytest.skip("No PRD environment found in database")
|
|
76
|
+
|
|
77
|
+
# Create an incident with P1/P2 priority in Post-mortem status with PRD env
|
|
78
|
+
incident = IncidentFactory.create(
|
|
79
|
+
priority=p2_priority,
|
|
80
|
+
environment=prd_env,
|
|
81
|
+
_status=IncidentStatus.POST_MORTEM,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Create form with the incident
|
|
85
|
+
form = UpdateStatusForm(incident=incident)
|
|
86
|
+
|
|
87
|
+
# Check that CLOSED is in the choices
|
|
88
|
+
status_choices = dict(form.fields["status"].choices)
|
|
89
|
+
assert IncidentStatus.CLOSED in status_choices
|
|
90
|
+
|
|
91
|
+
def test_p3_plus_can_skip_postmortem(self):
|
|
92
|
+
"""P3+ incidents can go directly from Mitigated to Closed and should not have post-mortem."""
|
|
93
|
+
# Get existing P3+ priority that doesn't need post-mortem
|
|
94
|
+
p3_priority = Priority.objects.filter(
|
|
95
|
+
value__gte=3, needs_postmortem=False
|
|
96
|
+
).first()
|
|
97
|
+
|
|
98
|
+
if not p3_priority:
|
|
99
|
+
# If no P3+ without postmortem, skip the test
|
|
100
|
+
pytest.skip("No P3+ priority with needs_postmortem=False found in database")
|
|
101
|
+
|
|
102
|
+
# Create an incident with P3+ priority in Mitigated status
|
|
103
|
+
incident = IncidentFactory.create(
|
|
104
|
+
priority=p3_priority,
|
|
105
|
+
_status=IncidentStatus.MITIGATED,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Create form with the incident
|
|
109
|
+
form = UpdateStatusForm(incident=incident)
|
|
110
|
+
|
|
111
|
+
# Check that CLOSED is in the choices for P3+ but POST_MORTEM is NOT
|
|
112
|
+
status_choices = dict(form.fields["status"].choices)
|
|
113
|
+
assert IncidentStatus.CLOSED in status_choices
|
|
114
|
+
assert IncidentStatus.POST_MORTEM not in status_choices, "P3+ incidents should not have post-mortem status available"
|
|
115
|
+
|
|
116
|
+
def test_p1_p2_cannot_skip_postmortem_from_mitigating(self):
|
|
117
|
+
"""P1/P2 incidents cannot go directly from Mitigating to Closed."""
|
|
118
|
+
|
|
119
|
+
# Get existing P1 or P2 priority that needs post-mortem
|
|
120
|
+
p1_priority = Priority.objects.filter(
|
|
121
|
+
value__in=[1, 2], needs_postmortem=True
|
|
122
|
+
).first()
|
|
123
|
+
|
|
124
|
+
if not p1_priority:
|
|
125
|
+
pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
|
|
126
|
+
|
|
127
|
+
# Get PRD environment (required for needs_postmortem to be True)
|
|
128
|
+
prd_env = Environment.objects.filter(value="PRD").first()
|
|
129
|
+
if not prd_env:
|
|
130
|
+
pytest.skip("No PRD environment found in database")
|
|
131
|
+
|
|
132
|
+
# Create an incident with P1/P2 priority in MITIGATING (Mitigating) status with PRD env
|
|
133
|
+
incident = IncidentFactory.create(
|
|
134
|
+
priority=p1_priority,
|
|
135
|
+
environment=prd_env,
|
|
136
|
+
_status=IncidentStatus.MITIGATING, # Test from MITIGATING (Mitigating), not MITIGATED
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Create form with the incident
|
|
140
|
+
form = UpdateStatusForm(incident=incident)
|
|
141
|
+
|
|
142
|
+
# Check that CLOSED and POST_MORTEM are not in the choices from MITIGATING status
|
|
143
|
+
status_choices = dict(form.fields["status"].choices)
|
|
144
|
+
assert IncidentStatus.CLOSED not in status_choices
|
|
145
|
+
assert IncidentStatus.POST_MORTEM not in status_choices, "P1/P2 should not be able to go to post-mortem from Mitigating"
|
|
146
|
+
|
|
147
|
+
def test_p3_can_skip_from_investigating_to_closed(self):
|
|
148
|
+
"""P3+ incidents can go directly from Investigating to Closed."""
|
|
149
|
+
# Get existing P3+ priority that doesn't need post-mortem
|
|
150
|
+
p3_priority = Priority.objects.filter(
|
|
151
|
+
value__gte=3, needs_postmortem=False
|
|
152
|
+
).first()
|
|
153
|
+
|
|
154
|
+
if not p3_priority:
|
|
155
|
+
pytest.skip("No P3+ priority with needs_postmortem=False found in database")
|
|
156
|
+
|
|
157
|
+
# Create an incident with P3+ priority in INVESTIGATING status
|
|
158
|
+
incident = IncidentFactory.create(
|
|
159
|
+
priority=p3_priority,
|
|
160
|
+
_status=IncidentStatus.INVESTIGATING, # Test from INVESTIGATING
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Create form with the incident
|
|
164
|
+
form = UpdateStatusForm(incident=incident)
|
|
165
|
+
|
|
166
|
+
# Check that CLOSED is in the choices from INVESTIGATING status for P3+ but POST_MORTEM is NOT
|
|
167
|
+
status_choices = dict(form.fields["status"].choices)
|
|
168
|
+
assert IncidentStatus.CLOSED in status_choices
|
|
169
|
+
assert IncidentStatus.POST_MORTEM not in status_choices, "P3+ incidents should not have post-mortem status available"
|
|
170
|
+
|
|
171
|
+
def test_p1_p2_can_go_from_mitigated_to_postmortem(self):
|
|
172
|
+
"""P1/P2 incidents can go from Mitigated to Post-mortem."""
|
|
173
|
+
|
|
174
|
+
# Get existing P1 or P2 priority that needs post-mortem
|
|
175
|
+
p1_priority = Priority.objects.filter(
|
|
176
|
+
value__in=[1, 2], needs_postmortem=True
|
|
177
|
+
).first()
|
|
178
|
+
|
|
179
|
+
if not p1_priority:
|
|
180
|
+
pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
|
|
181
|
+
|
|
182
|
+
# Get PRD environment
|
|
183
|
+
prd_env = Environment.objects.filter(value="PRD").first()
|
|
184
|
+
if not prd_env:
|
|
185
|
+
pytest.skip("No PRD environment found in database")
|
|
186
|
+
|
|
187
|
+
# Create an incident with P1/P2 priority in Mitigated status with PRD env
|
|
188
|
+
incident = IncidentFactory.create(
|
|
189
|
+
priority=p1_priority,
|
|
190
|
+
environment=prd_env,
|
|
191
|
+
_status=IncidentStatus.MITIGATED, # MITIGATED = "Mitigated"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Create form with the incident
|
|
195
|
+
form = UpdateStatusForm(incident=incident)
|
|
196
|
+
|
|
197
|
+
# Check that POST_MORTEM is available but CLOSED is not
|
|
198
|
+
status_choices = dict(form.fields["status"].choices)
|
|
199
|
+
assert IncidentStatus.POST_MORTEM in status_choices, "P1/P2 should be able to go to Post-mortem from Mitigated"
|
|
200
|
+
assert IncidentStatus.CLOSED not in status_choices, "P1/P2 should NOT be able to skip Post-mortem"
|
|
201
|
+
|
|
202
|
+
def test_complete_workflow_transitions_p3_plus(self):
|
|
203
|
+
"""Test the correct workflow for P3+: can close with reason from early statuses, normal close from Mitigated."""
|
|
204
|
+
# Get P3+ priority
|
|
205
|
+
p3_priority = Priority.objects.filter(
|
|
206
|
+
value__gte=3, needs_postmortem=False
|
|
207
|
+
).first()
|
|
208
|
+
|
|
209
|
+
if not p3_priority:
|
|
210
|
+
pytest.skip("No P3+ priority found")
|
|
211
|
+
|
|
212
|
+
# Test workflow for P3+
|
|
213
|
+
test_cases = [
|
|
214
|
+
(IncidentStatus.OPEN, True), # Can close with reason
|
|
215
|
+
(IncidentStatus.INVESTIGATING, True), # Can close with reason
|
|
216
|
+
(IncidentStatus.MITIGATING, False), # Cannot close from Mitigating (must go to Mitigated first)
|
|
217
|
+
(IncidentStatus.MITIGATED, True), # Can close normally from Mitigated
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
for current_status, should_have_closed in test_cases:
|
|
221
|
+
incident = IncidentFactory.create(
|
|
222
|
+
priority=p3_priority,
|
|
223
|
+
_status=current_status,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
form = UpdateStatusForm(incident=incident)
|
|
227
|
+
status_choices = dict(form.fields["status"].choices)
|
|
228
|
+
|
|
229
|
+
if should_have_closed:
|
|
230
|
+
assert IncidentStatus.CLOSED in status_choices, f"P3+ should be able to go to Closed from {current_status.label}"
|
|
231
|
+
else:
|
|
232
|
+
assert IncidentStatus.CLOSED not in status_choices, f"P3+ should NOT be able to go to Closed from {current_status.label}"
|
|
233
|
+
|
|
234
|
+
# P3+ should NEVER have post-mortem available
|
|
235
|
+
assert IncidentStatus.POST_MORTEM not in status_choices, f"P3+ should NEVER have post-mortem available from {current_status.label}"
|
|
236
|
+
|
|
237
|
+
def test_closure_reason_required_from_early_statuses(self):
|
|
238
|
+
"""Test that closure reason is required when closing from Opened or Investigating."""
|
|
239
|
+
# Test for both P1/P2 and P3+ priorities
|
|
240
|
+
priorities = [
|
|
241
|
+
Priority.objects.filter(value__in=[1, 2]).first(), # P1/P2
|
|
242
|
+
Priority.objects.filter(value__gte=3).first(), # P3+
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
for priority in priorities:
|
|
246
|
+
if not priority:
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# Test from Opened and Investigating
|
|
250
|
+
for status in [IncidentStatus.OPEN, IncidentStatus.INVESTIGATING]:
|
|
251
|
+
incident = IncidentFactory.create(
|
|
252
|
+
priority=priority,
|
|
253
|
+
_status=status,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Check that requires_closure_reason returns True
|
|
257
|
+
assert UpdateStatusForm.requires_closure_reason(
|
|
258
|
+
incident, IncidentStatus.CLOSED
|
|
259
|
+
), f"Should require closure reason from {status.label} for {priority.name}"
|
|
260
|
+
|
|
261
|
+
# Test from other statuses - should NOT require reason
|
|
262
|
+
for status in [IncidentStatus.MITIGATING, IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM]:
|
|
263
|
+
incident = IncidentFactory.create(
|
|
264
|
+
priority=priority,
|
|
265
|
+
_status=status,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Check that requires_closure_reason returns False
|
|
269
|
+
assert not UpdateStatusForm.requires_closure_reason(
|
|
270
|
+
incident, IncidentStatus.CLOSED
|
|
271
|
+
), f"Should NOT require closure reason from {status.label} for {priority.name}"
|
|
272
|
+
|
|
273
|
+
def test_cannot_close_from_mitigating(self):
|
|
274
|
+
"""Test that incidents cannot be closed directly from Mitigating status."""
|
|
275
|
+
# Test for all priority levels
|
|
276
|
+
priorities = Priority.objects.all()
|
|
277
|
+
|
|
278
|
+
for priority in priorities:
|
|
279
|
+
incident = IncidentFactory.create(
|
|
280
|
+
priority=priority,
|
|
281
|
+
_status=IncidentStatus.MITIGATING, # Mitigating status
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
form = UpdateStatusForm(incident=incident)
|
|
285
|
+
status_choices = dict(form.fields["status"].choices)
|
|
286
|
+
|
|
287
|
+
# Should NOT have Closed option from Mitigating
|
|
288
|
+
assert IncidentStatus.CLOSED not in status_choices, f"Should NOT be able to close from Mitigating for {priority.name}"
|
|
289
|
+
|
|
290
|
+
# Should have Post-mortem option only for P1/P2 priorities that need postmortem
|
|
291
|
+
if priority.needs_postmortem:
|
|
292
|
+
# For P1/P2, should still NOT have post-mortem from MITIGATING (Mitigating)
|
|
293
|
+
assert IncidentStatus.POST_MORTEM not in status_choices, f"P1/P2 should NOT be able to go to Post-mortem from Mitigating for {priority.name}"
|
|
294
|
+
else:
|
|
295
|
+
# For P3+, should not have post-mortem at all
|
|
296
|
+
assert IncidentStatus.POST_MORTEM not in status_choices, f"P3+ should not have post-mortem available for {priority.name}"
|
|
297
|
+
|
|
298
|
+
def test_no_incident_shows_all_statuses(self):
|
|
299
|
+
"""When no incident is provided, all statuses should be available."""
|
|
300
|
+
# Create form without incident
|
|
301
|
+
form = UpdateStatusForm()
|
|
302
|
+
|
|
303
|
+
# Check that all statuses including CLOSED are in the choices
|
|
304
|
+
status_choices = dict(form.fields["status"].choices)
|
|
305
|
+
assert IncidentStatus.CLOSED in status_choices
|
|
306
|
+
assert IncidentStatus.POST_MORTEM in status_choices
|
|
307
|
+
|
|
308
|
+
def test_requires_closure_reason_non_closed_status(self):
|
|
309
|
+
"""Test requires_closure_reason with non-CLOSED target status."""
|
|
310
|
+
incident = IncidentFactory.create(_status=IncidentStatus.OPEN)
|
|
311
|
+
|
|
312
|
+
# Should not require reason for non-CLOSED statuses
|
|
313
|
+
assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.INVESTIGATING)
|
|
314
|
+
assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.MITIGATING)
|
|
315
|
+
assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.MITIGATED)
|
|
316
|
+
assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.POST_MORTEM)
|
|
317
|
+
|
|
318
|
+
def test_incident_status_edge_cases(self):
|
|
319
|
+
"""Test edge cases for incident status transitions."""
|
|
320
|
+
# Test with incident in default fallback case
|
|
321
|
+
p1_priority = Priority.objects.filter(
|
|
322
|
+
value__in=[1, 2], needs_postmortem=True
|
|
323
|
+
).first()
|
|
324
|
+
|
|
325
|
+
if not p1_priority:
|
|
326
|
+
pytest.skip("No P1/P2 priority found")
|
|
327
|
+
|
|
328
|
+
prd_env = Environment.objects.filter(value="PRD").first()
|
|
329
|
+
if not prd_env:
|
|
330
|
+
pytest.skip("No PRD environment found")
|
|
331
|
+
|
|
332
|
+
# Create incident with an undefined status (should hit default case)
|
|
333
|
+
incident = IncidentFactory.create(
|
|
334
|
+
priority=p1_priority,
|
|
335
|
+
environment=prd_env,
|
|
336
|
+
_status=IncidentStatus.CLOSED # Already closed
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
form = UpdateStatusForm(incident=incident)
|
|
340
|
+
status_choices = dict(form.fields["status"].choices)
|
|
341
|
+
|
|
342
|
+
# Should use default choices_lte for unknown/closed status
|
|
343
|
+
assert IncidentStatus.CLOSED in status_choices
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Test complete workflow transitions according to the diagram.
|
|
2
|
+
|
|
3
|
+
Workflow transitions:
|
|
4
|
+
|
|
5
|
+
P1/P2:
|
|
6
|
+
- OPEN → [INVESTIGATING, CLOSED (avec reason form)]
|
|
7
|
+
- INVESTIGATING → [MITIGATING, CLOSED (avec reason form)]
|
|
8
|
+
- MITIGATING → [MITIGATED]
|
|
9
|
+
- MITIGATED → [POST_MORTEM]
|
|
10
|
+
- POST_MORTEM → [CLOSED]
|
|
11
|
+
|
|
12
|
+
P3/P4/P5:
|
|
13
|
+
- OPEN → [INVESTIGATING, CLOSED (avec reason form)]
|
|
14
|
+
- INVESTIGATING → [MITIGATING, CLOSED (avec reason form)]
|
|
15
|
+
- MITIGATING → [MITIGATED]
|
|
16
|
+
- MITIGATED → [CLOSED]
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
from django.test import TestCase
|
|
22
|
+
|
|
23
|
+
from firefighter.incidents.enums import IncidentStatus
|
|
24
|
+
from firefighter.incidents.factories import IncidentFactory
|
|
25
|
+
from firefighter.incidents.forms.update_status import UpdateStatusForm
|
|
26
|
+
from firefighter.incidents.models import Environment, Priority
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.django_db
|
|
30
|
+
class TestCompleteWorkflowTransitions(TestCase):
|
|
31
|
+
"""Test that all workflow transitions are implemented correctly."""
|
|
32
|
+
|
|
33
|
+
def test_p1_p2_complete_workflow_transitions(self):
|
|
34
|
+
"""Test all P1/P2 workflow transitions according to the diagram."""
|
|
35
|
+
# Get P1/P2 priority
|
|
36
|
+
p1_priority = Priority.objects.filter(
|
|
37
|
+
value__in=[1, 2], needs_postmortem=True
|
|
38
|
+
).first()
|
|
39
|
+
|
|
40
|
+
if not p1_priority:
|
|
41
|
+
pytest.skip("No P1/P2 priority found")
|
|
42
|
+
|
|
43
|
+
prd_env = Environment.objects.filter(value="PRD").first()
|
|
44
|
+
if not prd_env:
|
|
45
|
+
pytest.skip("No PRD environment found")
|
|
46
|
+
|
|
47
|
+
# Test all transitions
|
|
48
|
+
transitions = [
|
|
49
|
+
# Test transitions from each status
|
|
50
|
+
(IncidentStatus.OPEN, [IncidentStatus.INVESTIGATING, IncidentStatus.CLOSED]),
|
|
51
|
+
(IncidentStatus.INVESTIGATING, [IncidentStatus.MITIGATING, IncidentStatus.CLOSED]),
|
|
52
|
+
(IncidentStatus.MITIGATING, [IncidentStatus.MITIGATED]),
|
|
53
|
+
(IncidentStatus.MITIGATED, [IncidentStatus.POST_MORTEM]),
|
|
54
|
+
(IncidentStatus.POST_MORTEM, [IncidentStatus.CLOSED]),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
for current_status, expected_statuses in transitions:
|
|
58
|
+
incident = IncidentFactory.create(
|
|
59
|
+
priority=p1_priority,
|
|
60
|
+
environment=prd_env,
|
|
61
|
+
_status=current_status,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
form = UpdateStatusForm(incident=incident)
|
|
65
|
+
status_choices = dict(form.fields["status"].choices)
|
|
66
|
+
available_statuses = list(status_choices.keys())
|
|
67
|
+
|
|
68
|
+
# Remove current status from available (can't transition to same status)
|
|
69
|
+
if current_status in available_statuses:
|
|
70
|
+
available_statuses.remove(current_status)
|
|
71
|
+
|
|
72
|
+
for expected_status in expected_statuses:
|
|
73
|
+
assert expected_status in available_statuses, (
|
|
74
|
+
f"P1/P2: From {current_status.label}, should be able to go to {expected_status.label}. "
|
|
75
|
+
f"Available: {[IncidentStatus(s).label for s in available_statuses]}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Verify no unexpected statuses are available
|
|
79
|
+
unexpected_statuses = set(available_statuses) - set(expected_statuses)
|
|
80
|
+
assert not unexpected_statuses, (
|
|
81
|
+
f"P1/P2: From {current_status.label}, unexpected statuses available: "
|
|
82
|
+
f"{[IncidentStatus(s).label for s in unexpected_statuses]}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def test_p3_plus_complete_workflow_transitions(self):
|
|
86
|
+
"""Test all P3+ workflow transitions according to the diagram."""
|
|
87
|
+
# Get P3+ priority
|
|
88
|
+
p3_priority = Priority.objects.filter(
|
|
89
|
+
value__gte=3, needs_postmortem=False
|
|
90
|
+
).first()
|
|
91
|
+
|
|
92
|
+
if not p3_priority:
|
|
93
|
+
pytest.skip("No P3+ priority found")
|
|
94
|
+
|
|
95
|
+
# Test all transitions
|
|
96
|
+
transitions = [
|
|
97
|
+
# Test transitions from each status
|
|
98
|
+
(IncidentStatus.OPEN, [IncidentStatus.INVESTIGATING, IncidentStatus.CLOSED]),
|
|
99
|
+
(IncidentStatus.INVESTIGATING, [IncidentStatus.MITIGATING, IncidentStatus.CLOSED]),
|
|
100
|
+
(IncidentStatus.MITIGATING, [IncidentStatus.MITIGATED]),
|
|
101
|
+
(IncidentStatus.MITIGATED, [IncidentStatus.CLOSED]),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
for current_status, expected_statuses in transitions:
|
|
105
|
+
incident = IncidentFactory.create(
|
|
106
|
+
priority=p3_priority,
|
|
107
|
+
_status=current_status,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
form = UpdateStatusForm(incident=incident)
|
|
111
|
+
status_choices = dict(form.fields["status"].choices)
|
|
112
|
+
available_statuses = list(status_choices.keys())
|
|
113
|
+
|
|
114
|
+
# Remove current status from available (can't transition to same status)
|
|
115
|
+
if current_status in available_statuses:
|
|
116
|
+
available_statuses.remove(current_status)
|
|
117
|
+
|
|
118
|
+
for expected_status in expected_statuses:
|
|
119
|
+
assert expected_status in available_statuses, (
|
|
120
|
+
f"P3+: From {current_status.label}, should be able to go to {expected_status.label}. "
|
|
121
|
+
f"Available: {[IncidentStatus(s).label for s in available_statuses]}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Verify no unexpected statuses are available (especially POST_MORTEM)
|
|
125
|
+
unexpected_statuses = set(available_statuses) - set(expected_statuses)
|
|
126
|
+
assert not unexpected_statuses, (
|
|
127
|
+
f"P3+: From {current_status.label}, unexpected statuses available: "
|
|
128
|
+
f"{[IncidentStatus(s).label for s in unexpected_statuses]}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Specifically verify POST_MORTEM is never available for P3+
|
|
132
|
+
assert IncidentStatus.POST_MORTEM not in available_statuses, (
|
|
133
|
+
f"P3+: POST_MORTEM should never be available, but found from {current_status.label}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def test_closure_reason_requirements(self):
|
|
137
|
+
"""Test that closure reason is required only from OPEN and INVESTIGATING."""
|
|
138
|
+
priorities = [
|
|
139
|
+
Priority.objects.filter(value__in=[1, 2]).first(), # P1/P2
|
|
140
|
+
Priority.objects.filter(value__gte=3).first(), # P3+
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
for priority in priorities:
|
|
144
|
+
if not priority:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# Should require reason from OPEN and INVESTIGATING
|
|
148
|
+
for status in [IncidentStatus.OPEN, IncidentStatus.INVESTIGATING]:
|
|
149
|
+
incident = IncidentFactory.create(
|
|
150
|
+
priority=priority,
|
|
151
|
+
_status=status,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
assert UpdateStatusForm.requires_closure_reason(
|
|
155
|
+
incident, IncidentStatus.CLOSED
|
|
156
|
+
), f"Should require closure reason from {status.label} for {priority.name}"
|
|
157
|
+
|
|
158
|
+
# Should NOT require reason from other statuses
|
|
159
|
+
for status in [IncidentStatus.MITIGATING, IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM]:
|
|
160
|
+
incident = IncidentFactory.create(
|
|
161
|
+
priority=priority,
|
|
162
|
+
_status=status,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
assert not UpdateStatusForm.requires_closure_reason(
|
|
166
|
+
incident, IncidentStatus.CLOSED
|
|
167
|
+
), f"Should NOT require closure reason from {status.label} for {priority.name}"
|
|
@@ -100,9 +100,9 @@ def test_incident_create_unauthorized(client: Client) -> None:
|
|
|
100
100
|
|
|
101
101
|
@pytest.mark.django_db
|
|
102
102
|
@pytest.mark.usefixtures("_debug")
|
|
103
|
-
def
|
|
104
|
-
"""This test ensures that the
|
|
105
|
-
response = client.get(reverse("incidents:
|
|
103
|
+
def test_incident_category_list(client: Client) -> None:
|
|
104
|
+
"""This test ensures that the incident category list is accessible."""
|
|
105
|
+
response = client.get(reverse("incidents:incident-category-list"))
|
|
106
106
|
|
|
107
107
|
assert response.status_code == 302
|
|
108
108
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
|
|
8
|
+
from firefighter.incidents.factories import GroupFactory, IncidentCategoryFactory
|
|
9
|
+
from firefighter.incidents.models.incident_category import (
|
|
10
|
+
IncidentCategory,
|
|
11
|
+
IncidentCategoryFilterSet,
|
|
12
|
+
IncidentCategoryManager,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.mark.django_db
|
|
17
|
+
class TestIncidentCategoryManager:
|
|
18
|
+
def test_queryset_with_mtbf_date_to_in_future(self):
|
|
19
|
+
"""Test that date_to is clamped to now() when it's in the future."""
|
|
20
|
+
# Given
|
|
21
|
+
IncidentCategoryFactory()
|
|
22
|
+
manager = IncidentCategoryManager()
|
|
23
|
+
manager.model = IncidentCategory
|
|
24
|
+
|
|
25
|
+
# Create dates where date_to is in the future
|
|
26
|
+
now = timezone.now()
|
|
27
|
+
date_from = now - timedelta(days=10)
|
|
28
|
+
date_to = now + timedelta(days=5) # Future date
|
|
29
|
+
|
|
30
|
+
# When
|
|
31
|
+
result = manager.queryset_with_mtbf(date_from, date_to)
|
|
32
|
+
|
|
33
|
+
# Then
|
|
34
|
+
assert result is not None
|
|
35
|
+
assert list(result) == list(result) # Should not fail to execute
|
|
36
|
+
|
|
37
|
+
def test_queryset_with_mtbf_with_custom_queryset(self):
|
|
38
|
+
"""Test that custom queryset parameter is used."""
|
|
39
|
+
# Given
|
|
40
|
+
category1 = IncidentCategoryFactory()
|
|
41
|
+
category2 = IncidentCategoryFactory()
|
|
42
|
+
|
|
43
|
+
manager = IncidentCategoryManager()
|
|
44
|
+
manager.model = IncidentCategory
|
|
45
|
+
|
|
46
|
+
# Create a custom queryset that filters only category1
|
|
47
|
+
custom_queryset = IncidentCategory.objects.filter(id=category1.id)
|
|
48
|
+
|
|
49
|
+
date_from = timezone.now() - timedelta(days=10)
|
|
50
|
+
date_to = timezone.now() - timedelta(days=1)
|
|
51
|
+
|
|
52
|
+
# When
|
|
53
|
+
result = manager.queryset_with_mtbf(date_from, date_to, queryset=custom_queryset)
|
|
54
|
+
|
|
55
|
+
# Then
|
|
56
|
+
assert category1 in result
|
|
57
|
+
assert category2 not in result
|
|
58
|
+
|
|
59
|
+
def test_search_with_none_queryset(self):
|
|
60
|
+
"""Test search method when queryset is None."""
|
|
61
|
+
# Given
|
|
62
|
+
category = IncidentCategoryFactory(name="Test Category")
|
|
63
|
+
|
|
64
|
+
# When
|
|
65
|
+
result, is_empty = IncidentCategoryManager.search(None, "Test")
|
|
66
|
+
|
|
67
|
+
# Then
|
|
68
|
+
assert is_empty is False
|
|
69
|
+
assert category in result
|
|
70
|
+
|
|
71
|
+
def test_search_with_empty_search_term(self):
|
|
72
|
+
"""Test search method with empty/None search term."""
|
|
73
|
+
# Given
|
|
74
|
+
IncidentCategoryFactory(name="Category One")
|
|
75
|
+
IncidentCategoryFactory(name="Category Two")
|
|
76
|
+
queryset = IncidentCategory.objects.all()
|
|
77
|
+
|
|
78
|
+
# When - Test with None
|
|
79
|
+
result_none, is_empty_none = IncidentCategoryManager.search(queryset, None)
|
|
80
|
+
|
|
81
|
+
# When - Test with empty string
|
|
82
|
+
result_empty, is_empty_empty = IncidentCategoryManager.search(queryset, "")
|
|
83
|
+
|
|
84
|
+
# When - Test with whitespace
|
|
85
|
+
result_spaces, is_empty_spaces = IncidentCategoryManager.search(queryset, " ")
|
|
86
|
+
|
|
87
|
+
# Then - All should return the original queryset
|
|
88
|
+
assert is_empty_none is False
|
|
89
|
+
assert is_empty_empty is False
|
|
90
|
+
assert is_empty_spaces is False
|
|
91
|
+
|
|
92
|
+
original_ids = set(queryset.values_list("id", flat=True))
|
|
93
|
+
assert set(result_none.values_list("id", flat=True)) == original_ids
|
|
94
|
+
assert set(result_empty.values_list("id", flat=True)) == original_ids
|
|
95
|
+
assert set(result_spaces.values_list("id", flat=True)) == original_ids
|
|
96
|
+
|
|
97
|
+
def test_search_with_valid_search_term(self):
|
|
98
|
+
"""Test search method with valid search term."""
|
|
99
|
+
# Given
|
|
100
|
+
group = GroupFactory(name="Test Group", description="Group for testing")
|
|
101
|
+
category = IncidentCategoryFactory(
|
|
102
|
+
name="Infrastructure Issue",
|
|
103
|
+
description="Issues with infrastructure",
|
|
104
|
+
group=group
|
|
105
|
+
)
|
|
106
|
+
other_category = IncidentCategoryFactory(
|
|
107
|
+
name="Database Problem",
|
|
108
|
+
description="Database related issues"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# When - Search for "infrastructure"
|
|
112
|
+
result, is_empty = IncidentCategoryManager.search(None, "infrastructure")
|
|
113
|
+
|
|
114
|
+
# Then
|
|
115
|
+
assert is_empty is False
|
|
116
|
+
result_list = list(result)
|
|
117
|
+
assert category in result_list
|
|
118
|
+
# Note: other_category might also appear if search is broad
|
|
119
|
+
|
|
120
|
+
# When - Search for "database"
|
|
121
|
+
result_db, is_empty_db = IncidentCategoryManager.search(None, "database")
|
|
122
|
+
|
|
123
|
+
# Then
|
|
124
|
+
assert is_empty_db is False
|
|
125
|
+
result_db_list = list(result_db)
|
|
126
|
+
assert other_category in result_db_list
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.django_db
|
|
130
|
+
class TestIncidentCategoryFilterSet:
|
|
131
|
+
def test_incident_category_search(self):
|
|
132
|
+
"""Test the incident_category_search filter method."""
|
|
133
|
+
# Given
|
|
134
|
+
category = IncidentCategoryFactory(name="Network Issues")
|
|
135
|
+
queryset = IncidentCategory.objects.all()
|
|
136
|
+
|
|
137
|
+
# When
|
|
138
|
+
result = IncidentCategoryFilterSet.incident_category_search(queryset, "search", "network")
|
|
139
|
+
|
|
140
|
+
# Then
|
|
141
|
+
assert category in result
|
|
142
|
+
|
|
143
|
+
def test_metrics_period_filter(self):
|
|
144
|
+
"""Test the metrics period filter method."""
|
|
145
|
+
# Given
|
|
146
|
+
category = IncidentCategoryFactory()
|
|
147
|
+
queryset = IncidentCategory.objects.all()
|
|
148
|
+
|
|
149
|
+
# Create date range
|
|
150
|
+
now = timezone.now()
|
|
151
|
+
date_from = now - timedelta(days=30)
|
|
152
|
+
date_to = now
|
|
153
|
+
value = (date_from, date_to, None, None)
|
|
154
|
+
|
|
155
|
+
# When
|
|
156
|
+
result = IncidentCategoryFilterSet.metrics_period_filter(queryset, "metrics", value)
|
|
157
|
+
|
|
158
|
+
# Then
|
|
159
|
+
# Should return a queryset with mtbf annotation
|
|
160
|
+
assert result is not None
|
|
161
|
+
assert category in result
|
|
162
|
+
|
|
163
|
+
# Check that the mtbf annotation is present (will be None if no metrics)
|
|
164
|
+
category_with_mtbf = result.get(id=category.id)
|
|
165
|
+
assert hasattr(category_with_mtbf, "mtbf")
|