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:
parent
871b09a04c
commit
061123e124
9 changed files with 142 additions and 39 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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']) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue