Skip to content

Commit

Permalink
Merge branch 'dev' into rares/alert-groups-rebranch
Browse files Browse the repository at this point in the history
  • Loading branch information
teodosii committed Mar 30, 2023
2 parents ec25abe + 73343d4 commit 4b1f495
Show file tree
Hide file tree
Showing 12 changed files with 213 additions and 42 deletions.
16 changes: 16 additions & 0 deletions .github/workflows/issues_add_to_project.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Add issues to projects

on:
issues:
types:
- opened

jobs:
add-to-project:
name: Add OSS issues to team's kanban board
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v0.4.1
with:
project-url: https://github.com/orgs/grafana/projects/119
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased
## v1.2.6 (2023-03-30)

### Fixed

- Fixed bug when web schedules/shifts use non-UTC timezone and shift is deleted by @matiasb ([#1661](https://github.com/grafana/oncall/pull/1661))

## v1.2.5 (2023-03-30)

### Fixed

- Fixed a bug with Slack links not working in the plugin UI ([#1671](https://github.com/grafana/oncall/pull/1671))

## v1.2.4 (2023-03-30)

### Added

- Added the ability to change the team for escalation chains by @maskin25, @iskhakov and @vadimkerr ([#1658](https://github.com/grafana/oncall/pull/1658))

### Fixed

Expand Down
4 changes: 2 additions & 2 deletions engine/apps/alerts/models/escalation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ class Meta:
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():
Expand Down
2 changes: 1 addition & 1 deletion engine/apps/alerts/tests/test_escalation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
):
Expand Down
54 changes: 54 additions & 0 deletions engine/apps/api/tests/test_escalation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 12 additions & 3 deletions engine/apps/api/views/escalation_chain.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,6 +15,7 @@
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 (
Expand Down Expand Up @@ -120,14 +121,22 @@ def perform_update(self, serializer):
@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,
Expand Down
4 changes: 2 additions & 2 deletions engine/apps/schedules/models/custom_on_call_shift.py
Original file line number Diff line number Diff line change
Expand Up @@ -584,8 +584,8 @@ def event_ical_rules(self):
if self.week_start is not None:
rules["wkst"] = CustomOnCallShift.ICAL_WEEKDAY_MAP[self.week_start]
if self.until is not None:
time_zone = self.time_zone if self.time_zone is not None else "UTC"
rules["until"] = self.convert_dt_to_schedule_timezone(self.until, time_zone)
# RRULE UNTIL values must be specified in UTC when DTSTART is timezone-aware
rules["until"] = self.convert_dt_to_schedule_timezone(self.until, "UTC")
return rules

@cached_property
Expand Down
40 changes: 40 additions & 0 deletions engine/apps/schedules/tests/test_custom_on_call_shift.py
Original file line number Diff line number Diff line change
Expand Up @@ -1625,3 +1625,43 @@ def test_delete_override(
if (starting_day + duration) < 0
else on_call_shift.duration < original_duration
)


@pytest.mark.django_db
def test_until_rrule_must_be_utc(
make_organization_and_user,
make_user_for_organization,
make_schedule,
make_on_call_shift,
):
organization, user_1 = make_organization_and_user()
user_2 = make_user_for_organization(organization)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, time_zone="Europe/Warsaw")

date = timezone.now().replace(microsecond=0) - timezone.timedelta(days=7)
data = {
"priority_level": 1,
"start": date,
"rotation_start": date,
"duration": timezone.timedelta(seconds=10800),
"frequency": CustomOnCallShift.FREQUENCY_WEEKLY,
"interval": 2,
"time_zone": "Europe/Warsaw",
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
rolling_users = [[user_1], [user_2]]
on_call_shift.add_rolling_users(rolling_users)

# finish the rotation, will set until value
on_call_shift.delete()

on_call_shift.refresh_from_db()
assert on_call_shift.until.tzname() == "UTC"
ical_data = on_call_shift.convert_to_ical()
ical_rrule_until = on_call_shift.until.strftime("%Y%m%dT%H%M%S")
expected_rrule = f"RRULE:FREQ=WEEKLY;UNTIL={ical_rrule_until}Z;INTERVAL=4;WKST=SU"

assert expected_rrule in ical_data
3 changes: 2 additions & 1 deletion grafana-plugin/src/containers/AlertRules/AlertRules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -348,6 +348,7 @@ class AlertRules extends React.Component<AlertRulesProps, AlertRulesState> {
)}
{channelFilterIdToCopyEscalationChain && (
<EscalationChainForm
mode={EscalationChainFormMode.Copy}
escalationChainId={escalationChainIdToCopy}
onHide={() => {
this.setState({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,30 @@ 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;
}

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;
Expand All @@ -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);

Expand All @@ -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'])}
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 4b1f495

Please sign in to comment.