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.
Files changed (136) hide show
  1. firefighter/_version.py +16 -3
  2. firefighter/api/serializers.py +17 -8
  3. firefighter/api/urls.py +8 -1
  4. firefighter/api/views/_base.py +1 -1
  5. firefighter/api/views/components.py +5 -5
  6. firefighter/api/views/incidents.py +9 -9
  7. firefighter/confluence/signals/incident_updated.py +2 -2
  8. firefighter/firefighter/settings/components/raid.py +3 -0
  9. firefighter/incidents/admin.py +24 -24
  10. firefighter/incidents/enums.py +22 -2
  11. firefighter/incidents/factories.py +14 -5
  12. firefighter/incidents/forms/close_incident.py +4 -4
  13. firefighter/incidents/forms/closure_reason.py +45 -0
  14. firefighter/incidents/forms/create_incident.py +4 -4
  15. firefighter/incidents/forms/unified_incident.py +406 -0
  16. firefighter/incidents/forms/update_status.py +91 -5
  17. firefighter/incidents/menus.py +2 -2
  18. firefighter/incidents/migrations/0005_enable_from_p1_to_p5_priority.py +7 -5
  19. firefighter/incidents/migrations/0009_update_sla.py +7 -5
  20. firefighter/incidents/migrations/0020_create_incident_category_model.py +64 -0
  21. firefighter/incidents/migrations/0021_copy_component_data_to_incident_category.py +57 -0
  22. firefighter/incidents/migrations/0022_add_incident_category_fields.py +34 -0
  23. firefighter/incidents/migrations/0023_populate_incident_category_references.py +57 -0
  24. firefighter/incidents/migrations/0024_remove_component_fields_and_model.py +26 -0
  25. firefighter/incidents/migrations/0025_make_incident_category_required.py +24 -0
  26. firefighter/incidents/migrations/0026_alter_incidentcategory_options_and_more.py +39 -0
  27. firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
  28. firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
  29. firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
  30. firefighter/incidents/models/__init__.py +1 -1
  31. firefighter/incidents/models/group.py +1 -1
  32. firefighter/incidents/models/incident.py +47 -20
  33. firefighter/incidents/models/{component.py → incident_category.py} +30 -29
  34. firefighter/incidents/models/incident_update.py +3 -3
  35. firefighter/incidents/static/css/main.min.css +1 -1
  36. firefighter/incidents/tables.py +9 -9
  37. firefighter/incidents/templates/layouts/partials/incident_card.html +1 -1
  38. firefighter/incidents/templates/layouts/partials/incident_timeline.html +2 -2
  39. firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
  40. firefighter/incidents/templates/pages/{component_detail.html → incident_category_detail.html} +13 -13
  41. firefighter/incidents/templates/pages/{component_list.html → incident_category_list.html} +2 -2
  42. firefighter/incidents/templates/pages/incident_detail.html +3 -3
  43. firefighter/incidents/urls.py +6 -6
  44. firefighter/incidents/views/components/details.py +9 -9
  45. firefighter/incidents/views/components/list.py +9 -9
  46. firefighter/incidents/views/reports.py +5 -5
  47. firefighter/incidents/views/users/details.py +2 -2
  48. firefighter/incidents/views/views.py +7 -7
  49. firefighter/jira_app/client.py +1 -1
  50. firefighter/logging/custom_json_formatter.py +2 -1
  51. firefighter/pagerduty/tasks/trigger_oncall.py +1 -1
  52. firefighter/raid/admin.py +0 -11
  53. firefighter/raid/apps.py +9 -26
  54. firefighter/raid/client.py +5 -5
  55. firefighter/raid/forms.py +84 -213
  56. firefighter/raid/migrations/0003_delete_raidarea.py +16 -0
  57. firefighter/raid/models.py +2 -21
  58. firefighter/raid/serializers.py +5 -4
  59. firefighter/raid/service.py +29 -27
  60. firefighter/raid/signals/incident_created.py +42 -15
  61. firefighter/raid/signals/incident_updated.py +3 -2
  62. firefighter/raid/utils.py +1 -1
  63. firefighter/raid/views/__init__.py +1 -1
  64. firefighter/slack/admin.py +8 -8
  65. firefighter/slack/management/commands/switch_test_users.py +272 -0
  66. firefighter/slack/messages/slack_messages.py +24 -9
  67. firefighter/slack/migrations/0005_add_incident_categories_fields.py +33 -0
  68. firefighter/slack/migrations/0006_copy_components_to_incident_categories.py +57 -0
  69. firefighter/slack/migrations/0007_remove_components_fields.py +22 -0
  70. firefighter/slack/migrations/0008_alter_conversation_incident_categories_and_more.py +33 -0
  71. firefighter/slack/models/conversation.py +3 -3
  72. firefighter/slack/models/incident_channel.py +1 -1
  73. firefighter/slack/models/user.py +1 -1
  74. firefighter/slack/models/user_group.py +3 -3
  75. firefighter/slack/rules.py +2 -2
  76. firefighter/slack/signals/create_incident_conversation.py +6 -0
  77. firefighter/slack/signals/get_users.py +2 -2
  78. firefighter/slack/signals/incident_updated.py +8 -2
  79. firefighter/slack/utils.py +2 -2
  80. firefighter/slack/views/events/home.py +2 -2
  81. firefighter/slack/views/modals/__init__.py +4 -0
  82. firefighter/slack/views/modals/base_modal/form_utils.py +78 -0
  83. firefighter/slack/views/modals/close.py +18 -5
  84. firefighter/slack/views/modals/closure_reason.py +193 -0
  85. firefighter/slack/views/modals/open.py +83 -12
  86. firefighter/slack/views/modals/opening/check_current_incidents.py +2 -2
  87. firefighter/slack/views/modals/opening/details/unified.py +203 -0
  88. firefighter/slack/views/modals/opening/select_impact.py +5 -2
  89. firefighter/slack/views/modals/opening/set_details.py +3 -2
  90. firefighter/slack/views/modals/postmortem.py +10 -2
  91. firefighter/slack/views/modals/update_status.py +32 -6
  92. firefighter/slack/views/modals/utils.py +51 -0
  93. firefighter_fixtures/incidents/{components.json → incident_categories.json} +52 -52
  94. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +2 -2
  95. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +133 -88
  96. firefighter_tests/conftest.py +4 -5
  97. firefighter_tests/test_api/test_api_landbot.py +1 -1
  98. firefighter_tests/test_firefighter/test_sso.py +146 -0
  99. firefighter_tests/test_incidents/test_enums.py +100 -0
  100. firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
  101. firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
  102. firefighter_tests/test_incidents/test_forms/test_form_utils.py +15 -15
  103. firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
  104. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
  105. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
  106. firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
  107. firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
  108. firefighter_tests/test_incidents/test_incident_urls.py +3 -3
  109. firefighter_tests/test_incidents/test_models/test_incident_category.py +165 -0
  110. firefighter_tests/test_incidents/test_models/test_incident_model.py +70 -2
  111. firefighter_tests/test_raid/conftest.py +154 -0
  112. firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
  113. firefighter_tests/test_raid/test_priority_mapping.py +267 -0
  114. firefighter_tests/test_raid/test_raid_client.py +580 -0
  115. firefighter_tests/test_raid/test_raid_forms.py +552 -0
  116. firefighter_tests/test_raid/test_raid_models.py +185 -0
  117. firefighter_tests/test_raid/test_raid_serializers.py +507 -0
  118. firefighter_tests/test_raid/test_raid_service.py +442 -0
  119. firefighter_tests/test_raid/test_raid_signals.py +187 -0
  120. firefighter_tests/test_raid/test_raid_views.py +196 -0
  121. firefighter_tests/test_slack/messages/__init__.py +0 -0
  122. firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
  123. firefighter_tests/test_slack/views/modals/conftest.py +140 -0
  124. firefighter_tests/test_slack/views/modals/test_close.py +71 -9
  125. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
  126. firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
  127. firefighter_tests/test_slack/views/modals/test_open.py +146 -2
  128. firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
  129. firefighter_tests/test_slack/views/modals/test_update_status.py +331 -7
  130. firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
  131. firefighter/raid/views/open_normal.py +0 -139
  132. firefighter/slack/views/modals/opening/details/critical.py +0 -88
  133. firefighter_fixtures/raid/area.json +0 -1
  134. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
  135. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
  136. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+ from django.contrib.auth.models import Group
7
+
8
+ from firefighter.firefighter.sso import link_auth_user
9
+ from firefighter.incidents.factories import UserFactory
10
+
11
+
12
+ @pytest.mark.django_db
13
+ class TestLinkAuthUser:
14
+ """Test cases for the link_auth_user function."""
15
+
16
+ def test_link_auth_user_no_roles_claim(self) -> None:
17
+ """Test that function returns early when no roles in claim."""
18
+ user = UserFactory.create()
19
+ claim = {"some_other_field": "value"}
20
+
21
+ link_auth_user(user, claim)
22
+
23
+ # No changes should be made
24
+ assert list(user.groups.all()) == []
25
+
26
+ def test_link_auth_user_roles_not_list(self) -> None:
27
+ """Test that function returns early when roles is not a list."""
28
+ user = UserFactory.create()
29
+ claim = {"roles": "not_a_list"}
30
+
31
+ link_auth_user(user, claim)
32
+
33
+ # No changes should be made
34
+ assert list(user.groups.all()) == []
35
+
36
+ def test_link_auth_user_with_back_office_role(self) -> None:
37
+ """Test that back_office role grants staff status."""
38
+ user = UserFactory.create(is_staff=False)
39
+ claim = {"roles": ["back_office", "other_role"]}
40
+
41
+ # Create a group for testing
42
+ Group.objects.create(name="other_role")
43
+
44
+ link_auth_user(user, claim)
45
+
46
+ user.refresh_from_db()
47
+ assert user.is_staff is True
48
+ # back_office should be removed from group assignment
49
+ assert list(user.groups.values_list("name", flat=True)) == ["other_role"]
50
+
51
+ def test_link_auth_user_back_office_already_staff(self) -> None:
52
+ """Test that back_office role doesn't change already staff user."""
53
+ user = UserFactory.create(is_staff=True)
54
+ claim = {"roles": ["back_office"]}
55
+
56
+ with patch.object(user, "save") as mock_save:
57
+ link_auth_user(user, claim)
58
+ # save() should not be called since user is already staff
59
+ mock_save.assert_not_called()
60
+
61
+ def test_link_auth_user_clears_existing_groups(self) -> None:
62
+ """Test that existing groups are cleared before adding new ones."""
63
+ user = UserFactory.create()
64
+ old_group = Group.objects.create(name="old_group")
65
+ new_group = Group.objects.create(name="new_group")
66
+
67
+ # Add user to old group
68
+ user.groups.add(old_group)
69
+ assert old_group in user.groups.all()
70
+
71
+ claim = {"roles": ["new_group"]}
72
+
73
+ link_auth_user(user, claim)
74
+
75
+ # Old group should be removed, new group added
76
+ assert list(user.groups.all()) == [new_group]
77
+
78
+ def test_link_auth_user_with_existing_groups(self) -> None:
79
+ """Test that multiple existing groups are added correctly."""
80
+ user = UserFactory.create()
81
+ group1 = Group.objects.create(name="group1")
82
+ group2 = Group.objects.create(name="group2")
83
+
84
+ claim = {"roles": ["group1", "group2"]}
85
+
86
+ link_auth_user(user, claim)
87
+
88
+ user_groups = list(user.groups.all())
89
+ assert group1 in user_groups
90
+ assert group2 in user_groups
91
+ assert len(user_groups) == 2
92
+
93
+ def test_link_auth_user_nonexistent_group_logs_warning(self, caplog) -> None:
94
+ """Test that nonexistent groups log a warning."""
95
+ user = UserFactory.create()
96
+ claim = {"roles": ["nonexistent_group"]}
97
+
98
+ with caplog.at_level("WARNING"):
99
+ link_auth_user(user, claim)
100
+
101
+ assert "Group nonexistent_group from SSO does not exist" in caplog.text
102
+ assert list(user.groups.all()) == []
103
+
104
+ def test_link_auth_user_mixed_existing_and_nonexistent_groups(self, caplog) -> None:
105
+ """Test with mix of existing and nonexistent groups."""
106
+ user = UserFactory.create()
107
+ existing_group = Group.objects.create(name="existing_group")
108
+
109
+ claim = {"roles": ["existing_group", "nonexistent_group"]}
110
+
111
+ with caplog.at_level("WARNING"):
112
+ link_auth_user(user, claim)
113
+
114
+ # Should add existing group and log warning for nonexistent
115
+ assert list(user.groups.all()) == [existing_group]
116
+ assert "Group nonexistent_group from SSO does not exist" in caplog.text
117
+
118
+ def test_link_auth_user_empty_roles_list(self) -> None:
119
+ """Test with empty roles list."""
120
+ user = UserFactory.create()
121
+ old_group = Group.objects.create(name="old_group")
122
+ user.groups.add(old_group)
123
+
124
+ claim = {"roles": []}
125
+
126
+ link_auth_user(user, claim)
127
+
128
+ # Groups should be cleared
129
+ assert list(user.groups.all()) == []
130
+
131
+ def test_link_auth_user_back_office_with_other_roles(self) -> None:
132
+ """Test back_office role with other roles in comprehensive scenario."""
133
+ user = UserFactory.create(is_staff=False)
134
+ admin_group = Group.objects.create(name="admin")
135
+ editor_group = Group.objects.create(name="editor")
136
+
137
+ claim = {"roles": ["back_office", "admin", "editor"]}
138
+
139
+ link_auth_user(user, claim)
140
+
141
+ user.refresh_from_db()
142
+ assert user.is_staff is True
143
+
144
+ user_groups = set(user.groups.all())
145
+ expected_groups = {admin_group, editor_group}
146
+ assert user_groups == expected_groups
@@ -0,0 +1,100 @@
1
+ """Test the incidents enums module."""
2
+ from __future__ import annotations
3
+
4
+ from firefighter.incidents.enums import ClosureReason, IncidentStatus
5
+
6
+
7
+ class TestIncidentStatus:
8
+ """Test IncidentStatus enum and its methods."""
9
+
10
+ def test_enum_values(self):
11
+ """Test that enum values are correctly defined."""
12
+ assert IncidentStatus.OPEN.value == 10
13
+ assert IncidentStatus.INVESTIGATING.value == 20
14
+ assert IncidentStatus.MITIGATING.value == 30
15
+ assert IncidentStatus.MITIGATED.value == 40
16
+ assert IncidentStatus.POST_MORTEM.value == 50
17
+ assert IncidentStatus.CLOSED.value == 60
18
+
19
+ def test_enum_labels(self):
20
+ """Test that enum labels are correctly defined."""
21
+ assert IncidentStatus.OPEN.label == "Open"
22
+ assert IncidentStatus.INVESTIGATING.label == "Investigating"
23
+ assert IncidentStatus.MITIGATING.label == "Mitigating"
24
+ assert IncidentStatus.MITIGATED.label == "Mitigated"
25
+ assert IncidentStatus.POST_MORTEM.label == "Post-mortem"
26
+ assert IncidentStatus.CLOSED.label == "Closed"
27
+
28
+ def test_lt_method(self):
29
+ """Test the lt static method."""
30
+ result = IncidentStatus.lt(30)
31
+ expected = [IncidentStatus.OPEN, IncidentStatus.INVESTIGATING]
32
+ assert result == expected
33
+
34
+ def test_lte_method(self):
35
+ """Test the lte static method."""
36
+ result = IncidentStatus.lte(30)
37
+ expected = [IncidentStatus.OPEN, IncidentStatus.INVESTIGATING, IncidentStatus.MITIGATING]
38
+ assert result == expected
39
+
40
+ def test_gt_method(self):
41
+ """Test the gt static method."""
42
+ result = IncidentStatus.gt(30)
43
+ expected = [IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM, IncidentStatus.CLOSED]
44
+ assert result == expected
45
+
46
+ def test_gte_method(self):
47
+ """Test the gte static method."""
48
+ result = IncidentStatus.gte(30)
49
+ expected = [IncidentStatus.MITIGATING, IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM, IncidentStatus.CLOSED]
50
+ assert result == expected
51
+
52
+ def test_choices_lt_method(self):
53
+ """Test the choices_lt static method."""
54
+ result = IncidentStatus.choices_lt(30)
55
+ expected = [(10, "Open"), (20, "Investigating")]
56
+ assert result == expected
57
+
58
+ def test_choices_lte_method(self):
59
+ """Test the choices_lte static method."""
60
+ result = IncidentStatus.choices_lte(30)
61
+ expected = [(10, "Open"), (20, "Investigating"), (30, "Mitigating")]
62
+ assert result == expected
63
+
64
+ def test_choices_lte_skip_postmortem_method(self):
65
+ """Test the choices_lte_skip_postmortem static method."""
66
+ result = IncidentStatus.choices_lte_skip_postmortem(60)
67
+ expected = [(10, "Open"), (20, "Investigating"), (30, "Mitigating"), (40, "Mitigated"), (60, "Closed")]
68
+ assert result == expected
69
+
70
+ # Test that POST_MORTEM is excluded
71
+ assert (50, "Post-mortem") not in result
72
+
73
+
74
+ class TestClosureReason:
75
+ """Test ClosureReason enum."""
76
+
77
+ def test_enum_values(self):
78
+ """Test that closure reason values are correctly defined."""
79
+ assert ClosureReason.RESOLVED.value == "resolved"
80
+ assert ClosureReason.DUPLICATE.value == "duplicate"
81
+ assert ClosureReason.FALSE_POSITIVE.value == "false_positive"
82
+ assert ClosureReason.SUPERSEDED.value == "superseded"
83
+ assert ClosureReason.EXTERNAL.value == "external"
84
+ assert ClosureReason.CANCELLED.value == "cancelled"
85
+
86
+ def test_enum_labels(self):
87
+ """Test that closure reason labels are correctly defined."""
88
+ assert ClosureReason.RESOLVED.label == "Resolved normally"
89
+ assert ClosureReason.DUPLICATE.label == "Duplicate incident"
90
+ assert ClosureReason.FALSE_POSITIVE.label == "False alarm - no actual issue"
91
+ assert ClosureReason.SUPERSEDED.label == "Superseded by another incident"
92
+ assert ClosureReason.EXTERNAL.label == "External dependency/known issue"
93
+ assert ClosureReason.CANCELLED.label == "Cancelled - no longer relevant"
94
+
95
+ def test_choices_available(self):
96
+ """Test that choices are available."""
97
+ choices = ClosureReason.choices
98
+ assert len(choices) == 6
99
+ assert ("resolved", "Resolved normally") in choices
100
+ assert ("duplicate", "Duplicate incident") in choices
@@ -0,0 +1,179 @@
1
+ """Fixtures for unified incident form tests."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from firefighter.incidents.factories import (
7
+ IncidentCategoryFactory,
8
+ UserFactory,
9
+ )
10
+ from firefighter.incidents.models import Environment, Priority
11
+ from firefighter.incidents.models.impact import ImpactLevel, ImpactType, LevelChoices
12
+ from firefighter.jira_app.models import JiraUser
13
+
14
+
15
+ @pytest.fixture
16
+ def priority_factory(db):
17
+ """Factory to create Priority instances."""
18
+
19
+ def _create(**kwargs):
20
+ value = kwargs.get("value", 1)
21
+ name = kwargs.get("name", f"P{value}")
22
+ set_as_default = kwargs.get("default", False)
23
+
24
+ # If default=True, clear any other defaults first
25
+ if set_as_default:
26
+ Priority.objects.filter(default=True).update(default=False)
27
+
28
+ defaults = {
29
+ "emoji": "🔴",
30
+ "order": value,
31
+ "default": set_as_default,
32
+ "enabled_create": True,
33
+ "enabled_update": True,
34
+ "needs_postmortem": value <= 2, # P1-P2 need postmortem
35
+ }
36
+ # Remove name and value from kwargs if present
37
+ kwargs_copy = kwargs.copy()
38
+ kwargs_copy.pop("name", None)
39
+ kwargs_copy.pop("value", None)
40
+ defaults.update(kwargs_copy)
41
+
42
+ priority, created = Priority.objects.get_or_create(
43
+ name=name,
44
+ value=value,
45
+ defaults=defaults,
46
+ )
47
+
48
+ # If already exists and we want it as default, just set that
49
+ if not created and set_as_default:
50
+ priority.default = True
51
+ priority.save(update_fields=["default"])
52
+
53
+ return priority
54
+
55
+ return _create
56
+
57
+
58
+ @pytest.fixture
59
+ def environment_factory(db):
60
+ """Factory to create Environment instances."""
61
+
62
+ def _create(**kwargs):
63
+ value = kwargs.get("value", "TST")
64
+ set_as_default = kwargs.get("default", False)
65
+
66
+ # If default=True, clear any other defaults first
67
+ if set_as_default:
68
+ Environment.objects.filter(default=True).update(default=False)
69
+
70
+ defaults = {
71
+ "description": f"Environment {value}",
72
+ "order": 1,
73
+ "default": set_as_default,
74
+ }
75
+ # Remove value and default from kwargs if present
76
+ kwargs_copy = kwargs.copy()
77
+ kwargs_copy.pop("value", None)
78
+ kwargs_copy.pop("default", None)
79
+ defaults.update(kwargs_copy)
80
+
81
+ environment, created = Environment.objects.get_or_create(
82
+ value=value,
83
+ defaults=defaults,
84
+ )
85
+
86
+ # If already exists and we want it as default, just set that
87
+ if not created and set_as_default:
88
+ environment.default = True
89
+ environment.save(update_fields=["default"])
90
+
91
+ return environment
92
+
93
+ return _create
94
+
95
+
96
+ @pytest.fixture
97
+ def impact_level_factory(db):
98
+ """Factory to create ImpactLevel instances."""
99
+
100
+ def _create(**kwargs):
101
+ # Handle impact__name syntax by extracting nested parameters
102
+ impact_type_data = {}
103
+ keys_to_remove = []
104
+ for key in list(kwargs.keys()):
105
+ if key.startswith("impact__"):
106
+ nested_key = key.split("__", 1)[1]
107
+ impact_type_data[nested_key] = kwargs[key]
108
+ keys_to_remove.append(key)
109
+ for key in keys_to_remove:
110
+ kwargs.pop(key)
111
+
112
+ # Create or get ImpactType
113
+ impact_type = kwargs.pop("impact_type", None)
114
+ if isinstance(impact_type, ImpactType):
115
+ pass # Already have ImpactType instance
116
+ elif impact_type_data:
117
+ impact_type_name = impact_type_data.get("name", "Test Impact")
118
+ impact_type, _ = ImpactType.objects.get_or_create(name=impact_type_name, defaults={
119
+ "emoji": "📊",
120
+ "help_text": f"Test {impact_type_name} impact",
121
+ "value": impact_type_name.lower().replace(" ", "_"),
122
+ "order": 10,
123
+ })
124
+ else:
125
+ impact_type_name = "Test Impact"
126
+ impact_type, _ = ImpactType.objects.get_or_create(name=impact_type_name, defaults={
127
+ "emoji": "📊",
128
+ "help_text": "Test impact",
129
+ "value": "test_impact",
130
+ "order": 10,
131
+ })
132
+
133
+ # Handle value parameter
134
+ value = kwargs.pop("value", LevelChoices.LOW)
135
+
136
+ defaults = {
137
+ "impact_type": impact_type,
138
+ "value": value,
139
+ "name": value.label if hasattr(value, "label") else "Test Level",
140
+ "emoji": "📊",
141
+ }
142
+ defaults.update(kwargs)
143
+ return ImpactLevel.objects.create(**defaults)
144
+
145
+ return _create
146
+
147
+
148
+ @pytest.fixture
149
+ def incident_category_factory(db):
150
+ """Factory to create IncidentCategory instances."""
151
+
152
+ def _create(**kwargs):
153
+ return IncidentCategoryFactory(**kwargs)
154
+
155
+ return _create
156
+
157
+
158
+ @pytest.fixture
159
+ def user_factory(db):
160
+ """Factory to create User instances."""
161
+
162
+ def _create(**kwargs):
163
+ return UserFactory(**kwargs)
164
+
165
+ return _create
166
+
167
+
168
+ @pytest.fixture
169
+ def jira_user_factory(db):
170
+ """Factory to create JiraUser instances."""
171
+
172
+ def _create(**kwargs):
173
+ user = kwargs.pop("user", None)
174
+ if user is None:
175
+ user = UserFactory()
176
+ jira_id = kwargs.get("id", f"jira-{user.id}")
177
+ return JiraUser.objects.create(id=jira_id, user=user, **kwargs)
178
+
179
+ return _create
@@ -0,0 +1,91 @@
1
+ """Test the closure reason form."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+ from django.test import TestCase
6
+
7
+ from firefighter.incidents.enums import ClosureReason
8
+ from firefighter.incidents.forms.closure_reason import IncidentClosureReasonForm
9
+
10
+
11
+ @pytest.mark.django_db
12
+ class TestIncidentClosureReasonForm(TestCase):
13
+ """Test the IncidentClosureReasonForm."""
14
+
15
+ def test_form_initialization(self):
16
+ """Test that the form initializes correctly."""
17
+ form = IncidentClosureReasonForm()
18
+
19
+ # Check that all required fields are present
20
+ assert "closure_reason" in form.fields
21
+ assert "closure_reference" in form.fields
22
+ assert "message" in form.fields
23
+
24
+ def test_closure_reason_field_choices(self):
25
+ """Test that closure reason field has correct choices."""
26
+ form = IncidentClosureReasonForm()
27
+ closure_reason_field = form.fields["closure_reason"]
28
+
29
+ # Check that closure reasons are available (except RESOLVED which is excluded)
30
+ choices = dict(closure_reason_field.choices)
31
+ assert "resolved" not in choices # RESOLVED is excluded
32
+ assert "duplicate" in choices
33
+ assert "false_positive" in choices
34
+ assert "superseded" in choices
35
+ assert "external" in choices
36
+ assert "cancelled" in choices
37
+
38
+ def test_valid_form_submission(self):
39
+ """Test form with valid data."""
40
+ data = {
41
+ "closure_reason": ClosureReason.DUPLICATE, # Use a valid choice (not RESOLVED)
42
+ "closure_reference": "Fixed by restarting service",
43
+ "message": "The incident has been resolved by restarting the affected service."
44
+ }
45
+ form = IncidentClosureReasonForm(data=data)
46
+ assert form.is_valid(), f"Form errors: {form.errors}"
47
+
48
+ def test_form_with_minimal_data(self):
49
+ """Test form with minimal required data."""
50
+ data = {
51
+ "closure_reason": ClosureReason.DUPLICATE,
52
+ "message": "Duplicate of incident #123"
53
+ }
54
+ form = IncidentClosureReasonForm(data=data)
55
+ assert form.is_valid(), f"Form errors: {form.errors}"
56
+
57
+ def test_form_missing_closure_reason(self):
58
+ """Test form validation when closure_reason is missing."""
59
+ data = {
60
+ "message": "Test message"
61
+ }
62
+ form = IncidentClosureReasonForm(data=data)
63
+ assert not form.is_valid()
64
+ assert "closure_reason" in form.errors
65
+
66
+ def test_form_missing_message(self):
67
+ """Test form validation when message is missing."""
68
+ data = {
69
+ "closure_reason": ClosureReason.DUPLICATE
70
+ }
71
+ form = IncidentClosureReasonForm(data=data)
72
+ assert not form.is_valid()
73
+ assert "message" in form.errors
74
+
75
+ def test_message_field_constraints(self):
76
+ """Test message field length constraints."""
77
+ # Test valid message (no length constraints defined in the form)
78
+ data = {
79
+ "closure_reason": ClosureReason.DUPLICATE,
80
+ "message": "This is a valid message that meets the requirements."
81
+ }
82
+ form = IncidentClosureReasonForm(data=data)
83
+ assert form.is_valid(), f"Form errors: {form.errors}"
84
+
85
+ def test_form_excludes_resolved_reason(self):
86
+ """Test that RESOLVED is excluded from choices."""
87
+ form = IncidentClosureReasonForm()
88
+ choices = dict(form.fields["closure_reason"].choices)
89
+
90
+ # RESOLVED should be excluded for early closure forms
91
+ assert "resolved" not in choices
@@ -6,8 +6,8 @@ from django.forms import Form
6
6
 
7
7
  from firefighter.incidents.enums import IncidentStatus
8
8
  from firefighter.incidents.forms.utils import EnumChoiceField, GroupedModelChoiceField
9
- from firefighter.incidents.models.component import Component
10
9
  from firefighter.incidents.models.group import Group
10
+ from firefighter.incidents.models.incident_category import IncidentCategory
11
11
 
12
12
 
13
13
  class EnumChoiceFieldForm(Form):
@@ -15,8 +15,8 @@ class EnumChoiceFieldForm(Form):
15
15
 
16
16
 
17
17
  class GroupedModelChoiceFieldForm(Form):
18
- component = GroupedModelChoiceField(
19
- choices_groupby="group", queryset=Component.objects.all()
18
+ incident_category = GroupedModelChoiceField(
19
+ choices_groupby="group", queryset=IncidentCategory.objects.all()
20
20
  )
21
21
 
22
22
 
@@ -26,10 +26,10 @@ def group() -> Group:
26
26
 
27
27
 
28
28
  @pytest.fixture
29
- def components(group: Group):
29
+ def incident_categories(group: Group):
30
30
  return [
31
- Component.objects.create(name="Issue category 1", group=group, order=1),
32
- Component.objects.create(name="Issue category 2", group=group, order=2),
31
+ IncidentCategory.objects.create(name="Issue category 1", group=group, order=1),
32
+ IncidentCategory.objects.create(name="Issue category 2", group=group, order=2),
33
33
  ]
34
34
 
35
35
 
@@ -47,27 +47,27 @@ def test_enum_choice_field_invalid() -> None:
47
47
 
48
48
 
49
49
  @pytest.mark.django_db
50
- def test_grouped_model_choice_field_valid(components: list[Component]):
51
- form = GroupedModelChoiceFieldForm({"component": components[0].id})
50
+ def test_grouped_model_choice_field_valid(incident_categories: list[IncidentCategory]):
51
+ form = GroupedModelChoiceFieldForm({"incident_category": incident_categories[0].id})
52
52
  assert form.is_valid()
53
- assert form.cleaned_data["component"] == components[0]
53
+ assert form.cleaned_data["incident_category"] == incident_categories[0]
54
54
 
55
55
 
56
56
  def test_grouped_model_choice_field_invalid() -> None:
57
- form = GroupedModelChoiceFieldForm({"component": "non-existent-id"})
57
+ form = GroupedModelChoiceFieldForm({"incident_category": "non-existent-id"})
58
58
  assert not form.is_valid()
59
- assert "component" in form.errors
59
+ assert "incident_category" in form.errors
60
60
 
61
61
 
62
62
  @pytest.fixture(scope="module")
63
63
  def test_grouped_model_choice_field_grouping(
64
- components: list[Component],
64
+ incident_categories: list[IncidentCategory],
65
65
  ):
66
66
  form = GroupedModelChoiceFieldForm()
67
67
  grouped_choices = list(
68
- form.fields["component"].iterator(field=form.fields["component"])
68
+ form.fields["incident_category"].iterator(field=form.fields["incident_category"])
69
69
  )
70
70
  assert len(grouped_choices) == 2 # One for the empty choice, and one for the group
71
- assert grouped_choices[0] == ("", form.fields["component"].empty_label)
72
- assert grouped_choices[1][0] == components[0].group
71
+ assert grouped_choices[0] == ("", form.fields["incident_category"].empty_label)
72
+ assert grouped_choices[1][0] == incident_categories[0].group
73
73
  assert len(grouped_choices[1][1]) == 2 # Two components in the group