Allow changing team for escalation chains (#1658)

# What this PR does

Allows changing team for escalation chains

## Which issue(s) this PR fixes

## Checklist

- [ ] Unit, integration, and e2e (if applicable) tests updated
- [ ] Documentation added (or `pr:no public docs` PR label added if not
required)
- [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)

---------

Co-authored-by: Ildar Iskhakov <ildar.iskhakov@grafana.com>
Co-authored-by: Vadim Stepanov <vadimkerr@gmail.com>
This commit is contained in:
Maxim Mordasov 2023-03-30 12:43:00 +03:00 committed by GitHub
parent 871b09a04c
commit 061123e124
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 142 additions and 39 deletions

View file

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added the ability to change the team for escalation chains
### Fixed
- Addressed bug with iOS mobile push notifications always being set to critical by @imtoori and @joeyorlando ([#1646](https://github.com/grafana/oncall/pull/1646))

View file

@ -46,11 +46,11 @@ class EscalationChain(models.Model):
def __str__(self):
return f"{self.pk}: {self.name}"
def make_copy(self, copy_name: str):
def make_copy(self, copy_name: str, team):
with transaction.atomic():
copied_chain = EscalationChain.objects.create(
organization=self.organization,
team=self.team,
team=team,
name=copy_name,
)
for escalation_policy in self.escalation_policies.all():

View file

@ -42,7 +42,7 @@ def test_copy_escalation_chain(
all_fields = EscalationPolicy._meta.fields # Note that m-t-m fields are in this list
fields_to_not_compare = ["id", "public_primary_key", "escalation_chain", "last_notified_user"]
fields_to_compare = list(map(lambda f: f.name, filter(lambda f: f.name not in fields_to_not_compare, all_fields)))
copied_chain = escalation_chain.make_copy(f"copy_{escalation_chain.name}")
copied_chain = escalation_chain.make_copy(f"copy_{escalation_chain.name}", None)
for policy_from_original, policy_from_copy in zip(
escalation_chain.escalation_policies.all(), copied_chain.escalation_policies.all()
):

View file

@ -74,3 +74,57 @@ def test_list_escalation_chains_filters(escalation_chain_internal_api_setup, mak
"display_name": escalation_chain.name,
}
]
@pytest.mark.django_db
@pytest.mark.parametrize(
"team_name,new_team_name",
[
(None, None),
(None, "team_1"),
("team_1", None),
("team_1", "team_1"),
("team_1", "team_2"),
],
)
def test_escalation_chain_copy(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_escalation_chain,
make_team,
team_name,
new_team_name,
):
organization, user, token = make_organization_and_user_with_plugin_token()
team = make_team(organization, name=team_name) if team_name else None
new_team = make_team(organization, name=new_team_name) if new_team_name else None
escalation_chain = make_escalation_chain(organization, team=team)
data = {
"name": "escalation_chain_updated",
"team": new_team.public_primary_key if new_team else "null",
}
client = APIClient()
url = reverse("api-internal:escalation_chain-copy", kwargs={"pk": escalation_chain.public_primary_key})
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["team"] == (new_team.public_primary_key if new_team else None)
@pytest.mark.django_db
def test_escalation_chain_copy_empty_name(
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_escalation_chain,
):
organization, user, token = make_organization_and_user_with_plugin_token()
escalation_chain = make_escalation_chain(organization)
client = APIClient()
url = reverse("api-internal:escalation_chain-copy", kwargs={"pk": escalation_chain.public_primary_key})
response = client.post(url, {"name": "", "team": "null"}, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST

View file

@ -1,7 +1,7 @@
from django.db.models import Count, Q
from django_filters import rest_framework as filters
from emoji import emojize
from rest_framework import viewsets
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
@ -15,6 +15,7 @@ from apps.api.serializers.escalation_chain import (
FilterEscalationChainSerializer,
)
from apps.auth_token.auth import PluginAuthentication
from apps.user_management.models import Team
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.mixins import (
@ -120,14 +121,22 @@ class EscalationChainViewSet(
@action(methods=["post"], detail=True)
def copy(self, request, pk):
name = request.data.get("name")
if name is None:
team_id = request.data.get("team")
if team_id == "null":
team_id = None
if not name:
raise BadRequest(detail={"name": ["This field may not be null."]})
else:
if EscalationChain.objects.filter(organization=request.auth.organization, name=name).exists():
raise BadRequest(detail={"name": ["Escalation chain with this name already exists."]})
obj = self.get_object()
copy = obj.make_copy(name)
try:
team = request.user.available_teams.get(public_primary_key=team_id) if team_id else None
except Team.DoesNotExist:
return Response(data={"error_code": "wrong_team"}, status=status.HTTP_403_FORBIDDEN)
copy = obj.make_copy(name, team)
serializer = self.get_serializer(copy)
write_resource_insight_log(
instance=copy,

View file

@ -28,7 +28,7 @@ import WithConfirm from 'components/WithConfirm/WithConfirm';
import { parseEmojis } from 'containers/AlertRules/AlertRules.helpers';
import { ChatOpsConnectors } from 'containers/AlertRules/parts';
import ChannelFilterForm from 'containers/ChannelFilterForm/ChannelFilterForm';
import EscalationChainForm from 'containers/EscalationChainForm/EscalationChainForm';
import EscalationChainForm, { EscalationChainFormMode } from 'containers/EscalationChainForm/EscalationChainForm';
import EscalationChainSteps from 'containers/EscalationChainSteps/EscalationChainSteps';
import GSelect from 'containers/GSelect/GSelect';
import { IntegrationSettingsTab } from 'containers/IntegrationSettings/IntegrationSettings.types';
@ -348,6 +348,7 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
)}
{channelFilterIdToCopyEscalationChain && (
<EscalationChainForm
mode={EscalationChainFormMode.Copy}
escalationChainId={escalationChainIdToCopy}
onHide={() => {
this.setState({

View file

@ -3,15 +3,22 @@ import React, { ChangeEvent, FC, useCallback, useState } from 'react';
import { Button, Field, HorizontalGroup, Input, Modal } from '@grafana/ui';
import cn from 'classnames/bind';
import GrafanaTeamSelect from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
import GSelect from 'containers/GSelect/GSelect';
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
import { GrafanaTeam } from 'models/grafana_team/grafana_team.types';
import { useStore } from 'state/useStore';
import styles from 'containers/EscalationChainForm/EscalationChainForm.module.css';
export enum EscalationChainFormMode {
Create = 'Create',
Copy = 'Copy',
Update = 'Update',
}
interface EscalationChainFormProps {
escalationChainId?: EscalationChain['id'];
mode: EscalationChainFormMode;
onHide: () => void;
onUpdate: (id: EscalationChain['id']) => void;
}
@ -19,7 +26,7 @@ interface EscalationChainFormProps {
const cx = cn.bind(styles);
const EscalationChainForm: FC<EscalationChainFormProps> = (props) => {
const { escalationChainId, onHide, onUpdate } = props;
const { escalationChainId, onHide, onUpdate, mode } = props;
const store = useStore();
const { escalationChainStore, userStore } = store;
@ -28,15 +35,21 @@ const EscalationChainForm: FC<EscalationChainFormProps> = (props) => {
const escalationChain = escalationChainId ? escalationChainStore.items[escalationChainId] : undefined;
const [name, setName] = useState<string | undefined>(escalationChain?.name);
const [selectedTeam, setSelectedTeam] = useState<GrafanaTeam['id']>(user.current_team);
const [name, setName] = useState<string | undefined>(
mode === EscalationChainFormMode.Copy ? `${escalationChain?.name} copy` : escalationChain?.name
);
const [selectedTeam, setSelectedTeam] = useState<GrafanaTeam['id']>(escalationChain?.team || user.current_team);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const onCreateClickCallback = useCallback(() => {
(escalationChainId
? escalationChainStore.clone(escalationChainId, { name, team: selectedTeam })
: escalationChainStore.create({ name, team: selectedTeam })
)
const promise =
mode === EscalationChainFormMode.Create
? escalationChainStore.create({ name, team: selectedTeam })
: mode === EscalationChainFormMode.Copy
? escalationChainStore.clone(escalationChainId, { name, team: selectedTeam })
: escalationChainStore.update(escalationChainId, { name, team: selectedTeam });
promise
.then((escalationChain: EscalationChain) => {
onUpdate(escalationChain.id);
@ -47,21 +60,27 @@ const EscalationChainForm: FC<EscalationChainFormProps> = (props) => {
name: data.response.data.name || data.response.data.detail || data.response.data.non_field_errors,
});
});
}, [name, selectedTeam]);
}, [name, selectedTeam, mode]);
const handleNameChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setName(event.target.value);
}, []);
return (
<Modal
isOpen
title={escalationChainId ? `Copy ${escalationChain.name}` : `New Escalation Chain`}
onDismiss={onHide}
>
<Modal isOpen title={`${mode} Escalation Chain`} onDismiss={onHide}>
<div className={cx('root')}>
<Field label="Assign to team">
<GrafanaTeamSelect withoutModal onSelect={setSelectedTeam} />
<GSelect
modelName="grafanaTeamStore"
displayField="name"
valueField="id"
showSearch
allowClear
placeholder="Select a team"
className={cx('team-select')}
onChange={setSelectedTeam}
value={selectedTeam}
/>
</Field>
<Field
invalid={Boolean(errors['name'])}
@ -76,7 +95,7 @@ const EscalationChainForm: FC<EscalationChainFormProps> = (props) => {
Cancel
</Button>
<Button variant="primary" onClick={onCreateClickCallback}>
{escalationChainId ? 'Copy' : 'Create'}
{`${mode} Escalation Chain`}
</Button>
</HorizontalGroup>
</div>

View file

@ -184,7 +184,7 @@ class RemoteFilters extends Component<RemoteFiltersProps, RemoteFiltersState> {
if (option.data.default) {
let defaultValue = option.data.default;
if (option.data.type === 'options') {
defaultValue = [defaultValue];
defaultValue = [option.data.default.value];
}
if (option.data.type === 'boolean') {
defaultValue = defaultValue === 'false' ? false : Boolean(defaultValue);

View file

@ -19,7 +19,7 @@ import Tutorial from 'components/Tutorial/Tutorial';
import { TutorialStep } from 'components/Tutorial/Tutorial.types';
import WithConfirm from 'components/WithConfirm/WithConfirm';
import EscalationChainCard from 'containers/EscalationChainCard/EscalationChainCard';
import EscalationChainForm from 'containers/EscalationChainForm/EscalationChainForm';
import EscalationChainForm, { EscalationChainFormMode } from 'containers/EscalationChainForm/EscalationChainForm';
import EscalationChainSteps from 'containers/EscalationChainSteps/EscalationChainSteps';
import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
@ -37,11 +37,10 @@ const cx = cn.bind(styles);
interface EscalationChainsPageProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
interface EscalationChainsPageState extends PageBaseState {
showCreateEscalationChainModal: boolean;
escalationChainIdToCopy: EscalationChain['id'];
modeToShowEscalationChainForm?: EscalationChainFormMode;
selectedEscalationChain: EscalationChain['id'];
escalationChainsFilters?: FiltersValues;
extraEscalationChains?: EscalationChain[]; // to render Escalation chain that is not present in searchResult dur to filters
extraEscalationChains?: EscalationChain[]; // to render Escalation chains that are not present in searchResult due to filters
}
export interface Filters {
@ -51,8 +50,6 @@ export interface Filters {
@observer
class EscalationChainsPage extends React.Component<EscalationChainsPageProps, EscalationChainsPageState> {
state: EscalationChainsPageState = {
showCreateEscalationChainModal: false,
escalationChainIdToCopy: undefined,
selectedEscalationChain: undefined,
errorData: initErrorDataState(),
};
@ -127,7 +124,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
const { extraEscalationChains } = this.state;
const { showCreateEscalationChainModal, escalationChainIdToCopy, selectedEscalationChain, errorData } = this.state;
const { modeToShowEscalationChainForm, selectedEscalationChain, errorData } = this.state;
const { escalationChainStore } = store;
const searchResult = escalationChainStore.getSearchResult();
@ -154,7 +151,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<Button
onClick={() => {
this.setState({ showCreateEscalationChainModal: true });
this.setState({ modeToShowEscalationChainForm: EscalationChainFormMode.Create });
}}
icon="plus"
className={cx('new-escalation-chain')}
@ -192,7 +189,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
variant="primary"
size="lg"
onClick={() => {
this.setState({ showCreateEscalationChainModal: true });
this.setState({ modeToShowEscalationChainForm: EscalationChainFormMode.Create });
}}
>
New Escalation Chain
@ -203,13 +200,15 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
/>
)}
</div>
{showCreateEscalationChainModal && (
{modeToShowEscalationChainForm && (
<EscalationChainForm
escalationChainId={escalationChainIdToCopy}
mode={modeToShowEscalationChainForm}
escalationChainId={
modeToShowEscalationChainForm === EscalationChainFormMode.Create ? undefined : selectedEscalationChain
}
onHide={() => {
this.setState({
showCreateEscalationChainModal: false,
escalationChainIdToCopy: undefined,
modeToShowEscalationChainForm: undefined,
});
}}
onUpdate={this.handleEscalationChainCreate}
@ -298,6 +297,18 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
</Text>
<div className={cx('buttons')}>
<HorizontalGroup>
<WithPermissionControlTooltip userAction={UserActions.EscalationChainsWrite}>
<IconButton
tooltip="Edit"
tooltipPlacement="top"
name="cog"
onClick={() => {
this.setState({
modeToShowEscalationChainForm: EscalationChainFormMode.Update,
});
}}
/>
</WithPermissionControlTooltip>
<WithPermissionControlTooltip userAction={UserActions.EscalationChainsWrite}>
<IconButton
tooltip="Copy"
@ -305,8 +316,7 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
name="copy"
onClick={() => {
this.setState({
showCreateEscalationChainModal: true,
escalationChainIdToCopy: selectedEscalationChain,
modeToShowEscalationChainForm: EscalationChainFormMode.Copy,
});
}}
/>
@ -372,11 +382,17 @@ class EscalationChainsPage extends React.Component<EscalationChainsPageProps, Es
};
handleEscalationChainCreate = async (id: EscalationChain['id']) => {
const { selectedEscalationChain } = this.state;
const { history } = this.props;
await this.applyFilters();
history.push(`${PLUGIN_ROOT}/escalations/${id}${window.location.search}`);
// because this page wouldn't detect query.id change
if (selectedEscalationChain === id) {
this.parseQueryParams();
}
};
enrichExtraEscalationChainsAndSelect = async (id: EscalationChain['id']) => {