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:
Vadim Stepanov 2023-11-06 10:31:12 +00:00 committed by GitHub
parent f80d035030
commit 94e2a8472d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 292 additions and 19 deletions

View file

@ -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",

View file

@ -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"}]}

View file

@ -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",
),
]

View file

@ -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))

View file

@ -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"""

View file

@ -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'),
),
]

View file

@ -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",
)
]

View file

@ -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

View file

@ -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

View file

@ -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 }) => {

View file

@ -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];
}

View file

@ -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]}

View file

@ -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 };

View file

@ -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 };
}
}