Alert group labels filter (#3238)
# What this PR does Adds a model for alert group labels and adds filtering functionality for labels on the alert groups page. ## Which issue(s) this PR fixes https://github.com/grafana/oncall-private/issues/2178 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --------- Co-authored-by: Maxim <maxim.mordasov@grafana.com>
This commit is contained in:
parent
f80d035030
commit
94e2a8472d
14 changed files with 292 additions and 19 deletions
|
|
@ -863,6 +863,46 @@ def test_get_filter_escalation_chain(
|
|||
assert len(response.data["results"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_filter_labels(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_for_organization,
|
||||
make_alert_receive_channel,
|
||||
make_channel_filter,
|
||||
make_alert_group,
|
||||
make_alert,
|
||||
make_alert_group_label_association,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
channel_filter = make_channel_filter(alert_receive_channel, is_default=True)
|
||||
|
||||
alert_groups = []
|
||||
for _ in range(3):
|
||||
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
|
||||
make_alert(alert_group=alert_group, raw_request_data=alert_raw_request_data)
|
||||
alert_groups.append(alert_group)
|
||||
|
||||
make_alert_group_label_association(organization, alert_groups[0], key_name="a", value_name="b")
|
||||
make_alert_group_label_association(organization, alert_groups[0], key_name="c", value_name="d")
|
||||
make_alert_group_label_association(organization, alert_groups[1], key_name="a", value_name="b")
|
||||
make_alert_group_label_association(organization, alert_groups[2], key_name="c", value_name="d")
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:alertgroup-list")
|
||||
|
||||
response = client.get(
|
||||
url + "?label=a:b&label=c:d",
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["results"]) == 1
|
||||
assert response.json()["results"][0]["pk"] == alert_groups[0].public_primary_key
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"role,expected_status",
|
||||
|
|
|
|||
|
|
@ -299,3 +299,47 @@ def test_labels_permissions_create_update_actions(
|
|||
url = reverse("api-internal:create_label")
|
||||
response = client.post(url, format="json", data={}, **make_user_auth_headers(user, token))
|
||||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_group_labels_get_keys(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert_group_label_association,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(user.organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert_group_label_association(organization, alert_group, key_name="a", value_name="b")
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:alert_group_labels-get_keys")
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == [{"id": "a", "name": "a"}]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_alert_group_labels_get_key(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_alert_group_label_association,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(user.organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
make_alert_group_label_association(organization, alert_group, key_name="a", value_name="b")
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:alert_group_labels-get_key", kwargs={"key_id": "a"})
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {"key": {"id": "a", "name": "a"}, "values": [{"id": "b", "name": "b"}]}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from .views.escalation_chain import EscalationChainViewSet
|
|||
from .views.escalation_policy import EscalationPolicyView
|
||||
from .views.features import FeaturesAPIView
|
||||
from .views.integration_heartbeat import IntegrationHeartBeatView
|
||||
from .views.labels import LabelsViewSet
|
||||
from .views.labels import AlertGroupLabelsViewSet, LabelsViewSet
|
||||
from .views.live_setting import LiveSettingViewSet
|
||||
from .views.on_call_shifts import OnCallShiftView
|
||||
from .views.organization import (
|
||||
|
|
@ -129,3 +129,17 @@ urlpatterns += [
|
|||
),
|
||||
re_path(r"^labels/?$", LabelsViewSet.as_view({"post": "create_label"}), name="create_label"),
|
||||
]
|
||||
|
||||
# Alert group labels
|
||||
urlpatterns += [
|
||||
re_path(
|
||||
r"^alertgroups/labels/keys/?$",
|
||||
AlertGroupLabelsViewSet.as_view({"get": "get_keys"}),
|
||||
name="alert_group_labels-get_keys",
|
||||
),
|
||||
re_path(
|
||||
r"^alertgroups/labels/id/(?P<key_id>.+/?$)",
|
||||
AlertGroupLabelsViewSet.as_view({"get": "get_key"}),
|
||||
name="alert_group_labels-get_key",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGrou
|
|||
from apps.api.serializers.team import TeamSerializer
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.base.models.user_notification_policy_log_record import UserNotificationPolicyLogRecord
|
||||
from apps.labels.utils import is_labels_feature_enabled
|
||||
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
|
||||
from apps.user_management.models import Team, User
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
|
|
@ -331,10 +332,22 @@ class AlertGroupView(
|
|||
alert_receive_channels_qs = alert_receive_channels_qs.filter(*self.available_teams_lookup_args)
|
||||
|
||||
alert_receive_channels_ids = list(alert_receive_channels_qs.values_list("id", flat=True))
|
||||
queryset = AlertGroup.objects.filter(channel__in=alert_receive_channels_ids)
|
||||
|
||||
queryset = AlertGroup.objects.filter(
|
||||
channel__in=alert_receive_channels_ids,
|
||||
)
|
||||
# filter by labels
|
||||
labels = self.request.query_params.getlist("label")
|
||||
for label in labels:
|
||||
label_split = label.split(":")
|
||||
if len(label_split) != 2:
|
||||
continue
|
||||
key_name, value_name = label_split
|
||||
|
||||
# Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel
|
||||
queryset = queryset.filter(
|
||||
labels__organization=self.request.auth.organization,
|
||||
labels__key_name=key_name,
|
||||
labels__value_name=value_name,
|
||||
)
|
||||
|
||||
queryset = queryset.only("id")
|
||||
|
||||
|
|
@ -745,6 +758,15 @@ class AlertGroupView(
|
|||
},
|
||||
]
|
||||
|
||||
if is_labels_feature_enabled(self.request.auth.organization):
|
||||
filter_options.append(
|
||||
{
|
||||
"name": "label",
|
||||
"display_name": "Label",
|
||||
"type": "alert_group_labels",
|
||||
}
|
||||
)
|
||||
|
||||
if filter_name is not None:
|
||||
filter_options = list(filter(lambda f: filter_name in f["name"], filter_options))
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,14 @@ from common.api_helpers.exceptions import BadRequest
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LabelsViewSet(ViewSet):
|
||||
class LabelsFeatureFlagViewSet(ViewSet):
|
||||
def initial(self, request, *args, **kwargs):
|
||||
super().initial(request, *args, **kwargs)
|
||||
if not is_labels_feature_enabled(self.request.auth.organization):
|
||||
raise NotFound
|
||||
|
||||
|
||||
class LabelsViewSet(LabelsFeatureFlagViewSet):
|
||||
"""
|
||||
Proxy requests to labels-app to create/update labels
|
||||
"""
|
||||
|
|
@ -40,11 +47,6 @@ class LabelsViewSet(ViewSet):
|
|||
"rename_value": LegacyAccessControlRole.EDITOR,
|
||||
}
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
super().initial(request, *args, **kwargs)
|
||||
if not is_labels_feature_enabled(self.request.auth.organization):
|
||||
raise NotFound
|
||||
|
||||
@extend_schema(responses=LabelKeySerializer(many=True))
|
||||
def get_keys(self, request):
|
||||
"""List of labels keys"""
|
||||
|
|
@ -135,6 +137,41 @@ class LabelsViewSet(ViewSet):
|
|||
update_labels_cache.apply_async((label_data,))
|
||||
|
||||
|
||||
class AlertGroupLabelsViewSet(LabelsFeatureFlagViewSet):
|
||||
"""
|
||||
This viewset is similar to LabelsViewSet, but it works with alert group labels.
|
||||
Alert group labels are stored in the database, not in the label repo.
|
||||
"""
|
||||
|
||||
permission_classes = (IsAuthenticated, BasicRolePermission)
|
||||
authentication_classes = (PluginAuthentication,)
|
||||
basic_role_permissions = {
|
||||
"get_keys": LegacyAccessControlRole.VIEWER,
|
||||
"get_key": LegacyAccessControlRole.VIEWER,
|
||||
}
|
||||
|
||||
@extend_schema(responses=LabelKeySerializer(many=True))
|
||||
def get_keys(self, request):
|
||||
"""
|
||||
List of alert group label keys.
|
||||
IDs are the same as names to keep the response format consistent with LabelsViewSet.get_keys().
|
||||
"""
|
||||
names = self.request.auth.organization.alert_group_labels.values_list("key_name", flat=True).distinct()
|
||||
return Response([{"id": name, "name": name} for name in names])
|
||||
|
||||
@extend_schema(responses=LabelKeyValuesSerializer)
|
||||
def get_key(self, request, key_id):
|
||||
"""Key with the list of values. IDs and names are interchangeable (see get_keys() for more details)."""
|
||||
values = (
|
||||
self.request.auth.organization.alert_group_labels.filter(key_name=key_id)
|
||||
.values_list("value_name", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
return Response(
|
||||
{"key": {"id": key_id, "name": key_id}, "values": [{"id": value, "name": value} for value in values]}
|
||||
)
|
||||
|
||||
|
||||
class LabelsAssociatingMixin: # use for labelable objects views (ex. AlertReceiveChannelView)
|
||||
def filter_by_labels(self, queryset):
|
||||
"""Call this method in `get_queryset()` to add filtering by labels"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
# Generated by Django 4.2.6 on 2023-11-01 14:42
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('alerts', '0035_alter_alertreceivechannel_maintenance_author'),
|
||||
('user_management', '0017_alter_organization_maintenance_author'),
|
||||
('labels', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AlertGroupAssociatedLabel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key_name', models.CharField(max_length=200)),
|
||||
('value_name', models.CharField(max_length=200)),
|
||||
('alert_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='alerts.alertgroup')),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alert_group_labels', to='user_management.organization')),
|
||||
],
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='alertgroupassociatedlabel',
|
||||
constraint=models.UniqueConstraint(fields=('organization', 'key_name', 'value_name', 'alert_group'), name='unique_alert_group_label'),
|
||||
),
|
||||
]
|
||||
|
|
@ -112,3 +112,27 @@ class AlertReceiveChannelAssociatedLabel(AssociatedLabel):
|
|||
def get_associating_label_field_name() -> str:
|
||||
"""Returns ForeignKey field name for the associated model"""
|
||||
return "alert_receive_channel"
|
||||
|
||||
|
||||
class AlertGroupAssociatedLabel(models.Model):
|
||||
"""
|
||||
A model for alert group labels (similar to AlertReceiveChannelAssociatedLabel for integrations).
|
||||
The key difference is that alert group labels do not use label IDs, but rather key and value names explicitly.
|
||||
This is done to make alert group labels "static" (so they don't change when the labels are updated in label repo).
|
||||
"""
|
||||
|
||||
alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.CASCADE, related_name="labels")
|
||||
organization = models.ForeignKey(
|
||||
"user_management.Organization", on_delete=models.CASCADE, related_name="alert_group_labels"
|
||||
)
|
||||
|
||||
key_name = models.CharField(max_length=200)
|
||||
value_name = models.CharField(max_length=200)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["organization", "key_name", "value_name", "alert_group"],
|
||||
name="unique_alert_group_label",
|
||||
)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import factory
|
||||
|
||||
from apps.labels.models import AlertReceiveChannelAssociatedLabel, LabelKeyCache, LabelValueCache
|
||||
from apps.labels.models import (
|
||||
AlertGroupAssociatedLabel,
|
||||
AlertReceiveChannelAssociatedLabel,
|
||||
LabelKeyCache,
|
||||
LabelValueCache,
|
||||
)
|
||||
from common.utils import UniqueFaker
|
||||
|
||||
|
||||
|
|
@ -23,3 +28,8 @@ class LabelValueFactory(factory.DjangoModelFactory):
|
|||
class AlertReceiveChannelAssociatedLabelFactory(factory.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = AlertReceiveChannelAssociatedLabel
|
||||
|
||||
|
||||
class AlertGroupAssociatedLabelFactory(factory.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = AlertGroupAssociatedLabel
|
||||
|
|
|
|||
|
|
@ -57,7 +57,12 @@ from apps.base.tests.factories import (
|
|||
)
|
||||
from apps.email.tests.factories import EmailMessageFactory
|
||||
from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory
|
||||
from apps.labels.tests.factories import AlertReceiveChannelAssociatedLabelFactory, LabelKeyFactory, LabelValueFactory
|
||||
from apps.labels.tests.factories import (
|
||||
AlertGroupAssociatedLabelFactory,
|
||||
AlertReceiveChannelAssociatedLabelFactory,
|
||||
LabelKeyFactory,
|
||||
LabelValueFactory,
|
||||
)
|
||||
from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken
|
||||
from apps.phone_notifications.phone_backend import PhoneBackend
|
||||
from apps.phone_notifications.tests.factories import PhoneCallRecordFactory, SMSRecordFactory
|
||||
|
|
@ -974,3 +979,11 @@ def make_integration_label_association(make_label_key_and_value):
|
|||
)
|
||||
|
||||
return _make_integration_label_association
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_alert_group_label_association():
|
||||
def _make_alert_group_label_association(organization, alert_group, **kwargs):
|
||||
return AlertGroupAssociatedLabelFactory(alert_group=alert_group, organization=organization, **kwargs)
|
||||
|
||||
return _make_alert_group_label_association
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import styles from './Labels.module.css';
|
|||
const cx = cn.bind(styles);
|
||||
|
||||
interface LabelsFilterProps {
|
||||
filterType: 'labels' | 'alert_group_labels';
|
||||
autoFocus: boolean;
|
||||
className: string;
|
||||
value: string[];
|
||||
|
|
@ -19,22 +20,32 @@ interface LabelsFilterProps {
|
|||
}
|
||||
|
||||
const LabelsFilter = observer((props: LabelsFilterProps) => {
|
||||
const { className, autoFocus, value: propsValue, onChange } = props;
|
||||
const { filterType, className, autoFocus, value: propsValue, onChange } = props;
|
||||
|
||||
const [value, setValue] = useState([]);
|
||||
|
||||
const [keys, setKeys] = useState([]);
|
||||
|
||||
const { labelsStore } = useStore();
|
||||
const { alertGroupStore, labelsStore } = useStore();
|
||||
|
||||
const loadKeys =
|
||||
filterType === 'alert_group_labels'
|
||||
? alertGroupStore.loadLabelsKeys.bind(alertGroupStore)
|
||||
: labelsStore.loadKeys.bind(labelsStore);
|
||||
|
||||
const loadValuesForKey =
|
||||
filterType === 'alert_group_labels'
|
||||
? alertGroupStore.loadValuesForLabelKey.bind(alertGroupStore)
|
||||
: labelsStore.loadValuesForKey.bind(labelsStore);
|
||||
|
||||
useEffect(() => {
|
||||
labelsStore.loadKeys().then(setKeys);
|
||||
loadKeys().then(setKeys);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const keyValuePairs = (propsValue || []).map((k) => k.split(':'));
|
||||
|
||||
const promises = keyValuePairs.map(([keyId]) => labelsStore.loadValuesForKey(keyId));
|
||||
const promises = keyValuePairs.map(([keyId]) => loadValuesForKey(keyId));
|
||||
|
||||
const fetchKeyValues = async () => await Promise.all(promises);
|
||||
|
||||
|
|
@ -56,7 +67,7 @@ const LabelsFilter = observer((props: LabelsFilterProps) => {
|
|||
return new Promise((resolve) => {
|
||||
const keysFiltered = keys.filter((k) => k.name.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
const promises = keysFiltered.map((key) => labelsStore.loadValuesForKey(key.id));
|
||||
const promises = keysFiltered.map((key) => loadValuesForKey(key.id));
|
||||
|
||||
Promise.all(promises).then((list) => {
|
||||
const options = list.reduce((memo, { key, values }) => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ export function parseFilters(
|
|||
|
||||
let value: any = rawValue;
|
||||
|
||||
if (filterOption.type === 'options' || filterOption.type === 'team_select' || filterOption.type === 'labels') {
|
||||
if (
|
||||
filterOption.type === 'options' ||
|
||||
filterOption.type === 'team_select' ||
|
||||
filterOption.type === 'labels' ||
|
||||
filterOption.type === 'alert_group_labels'
|
||||
) {
|
||||
if (!Array.isArray(rawValue)) {
|
||||
value = [rawValue];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -330,8 +330,10 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
|
|||
);
|
||||
|
||||
case 'labels':
|
||||
case 'alert_group_labels':
|
||||
return (
|
||||
<LabelsFilter
|
||||
filterType={filter.type}
|
||||
autoFocus={autoFocus}
|
||||
className={cx('filter-select')}
|
||||
value={values[filter.name]}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export interface RemoteFiltersType {}
|
|||
export interface FilterOption {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
type: 'search' | 'options' | 'boolean' | 'daterange' | 'team_select' | 'labels';
|
||||
type: 'search' | 'options' | 'boolean' | 'daterange' | 'team_select' | 'labels' | 'alert_group_labels';
|
||||
href?: string;
|
||||
options?: SelectOption[];
|
||||
default?: { value: string };
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import qs from 'query-string';
|
|||
|
||||
import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_channel.types';
|
||||
import BaseStore from 'models/base_store';
|
||||
import { LabelKey } from 'models/label/label.types';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { makeRequest } from 'network';
|
||||
import { Mixpanel } from 'services/mixpanel';
|
||||
|
|
@ -437,4 +438,24 @@ export class AlertGroupStore extends BaseStore {
|
|||
data: { user_id: userId },
|
||||
}).catch(this.onApiError);
|
||||
}
|
||||
|
||||
@action
|
||||
public async loadLabelsKeys() {
|
||||
return await makeRequest(`/alertgroups/labels/keys/`, {});
|
||||
}
|
||||
|
||||
@action
|
||||
public async loadValuesForLabelKey(key: LabelKey['id'], search = '') {
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await makeRequest(`/alertgroups/labels/id/${key}`, {
|
||||
params: { search },
|
||||
});
|
||||
|
||||
const filteredValues = result.values.filter((v) => v.name.toLowerCase().includes(search.toLowerCase())); // TODO remove after backend search implementation
|
||||
|
||||
return { ...result, values: filteredValues };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue