Merge pull request #2105 from grafana/dev

v1.2.39
This commit is contained in:
Ildar Iskhakov 2023-06-06 10:11:37 +08:00 committed by GitHub
commit 6d3a2bc949
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 498 additions and 95 deletions

View file

@ -5,8 +5,20 @@ 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.39 (2023-06-06)
### Changed
- Do not hide not secret settings in the web plugin UI by @alexintech ([#1964](https://github.com/grafana/oncall/pull/1964))
## v1.2.36 (2023-06-02)
### Added
- Add public API endpoint to export a schedule's final shifts by @joeyorlando ([2047](https://github.com/grafana/oncall/pull/2047))
### Fixed
- Fix demo alert for inbound email integration by @vadimkerr ([#2081](https://github.com/grafana/oncall/pull/2081))

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/
canonical: https://grafana.com/docs/oncall/latest/
keywords:
- Grafana Cloud

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/escalation-chains-and-routes/
canonical: https://grafana.com/docs/oncall/latest/escalation-chains-and-routes/
title: Escalation Chains and Routes
weight: 600

View file

@ -1,6 +1,5 @@
---
aliases:
- /docs/oncall/latest/get-started/
- /getting-started/
canonical: https://grafana.com/docs/oncall/latest/get-started/
keywords:

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/integration-with-alert-sources/
canonical: https://grafana.com/docs/oncall/latest/integration-with-alert-sources/
keywords:
- Grafana Cloud

View file

@ -1,7 +1,6 @@
---
aliases:
- add-alertmanager/
- /docs/oncall/latest/integrations/available-integrations/configure-alertmanager/
canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-alertmanager/
keywords:
- Grafana Cloud

View file

@ -1,7 +1,6 @@
---
aliases:
- add-grafana-alerting/
- /docs/oncall/latest/integrations/available-integrations/configure-grafana-alerting/
canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-grafana-alerting/
keywords:
- Grafana Cloud

View file

@ -1,7 +1,6 @@
---
aliases:
- inbound-email/
- /docs/oncall/latest/integrations/available-integrations/configure-inbound-email/
canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-inbound-email/
keywords:
- Grafana Cloud

View file

@ -1,7 +1,6 @@
---
aliases:
- ../add-webhook-integration/
- /docs/oncall/latest/integrations/available-integrations/configure-webhook/
canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-webhook/
keywords:
- Grafana Cloud

View file

@ -1,7 +1,6 @@
---
aliases:
- add-zabbix/
- /docs/oncall/latest/integrations/available-integrations/configure-zabbix/
canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-zabbix/
keywords:
- Grafana Cloud

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/jinja2-templating/
canonical: https://grafana.com/docs/oncall/latest/jinja2-templating/
title: Jinja2 templating
weight: 1000

View file

@ -1,7 +1,5 @@
---
title: Mobile App
aliases:
- /docs/oncall/latest/mobile-app/
canonical: https://grafana.com/docs/oncall/latest/mobile-app/
keywords:
- Mobile App

View file

@ -1,7 +1,6 @@
---
aliases:
- ../../chat-options/configure-teams/
- /docs/oncall/latest/integrations/chatops-integrations/configure-teams/
canonical: https://grafana.com/docs/oncall/latest/integrations/chatops-integrations/configure-teams/
keywords:
- Grafana Cloud

View file

@ -1,7 +1,6 @@
---
aliases:
- ../../chat-options/configure-slack/
- /docs/oncall/latest/integrations/chatops-integrations/configure-slack/
canonical: https://grafana.com/docs/oncall/latest/integrations/chatops-integrations/configure-slack/
keywords:
- Grafana Cloud

View file

@ -1,7 +1,6 @@
---
aliases:
- ../../chat-options/configure-telegram/
- /docs/oncall/latest/integrations/chatops-integrations/configure-telegram/
canonical: https://grafana.com/docs/oncall/latest/integrations/chatops-integrations/configure-telegram/
keywords:
- Grafana Cloud

View file

@ -1,14 +1,13 @@
---
title: iCal on-call schedules
aliases:
- /docs/oncall/latest/on-call-schedules/ical-schedules/
canonical: https://grafana.com/docs/oncall/latest/on-call-schedules/ical-schedules/
description: "Learn how to manage on-call schedules with iCal import"
keywords:
- Grafana
- oncall
- on-call
- calendar
- calendar
title: Import on-call schedules
weight: 300
---

View file

@ -1,7 +1,5 @@
---
title: Web-based on-call schedules
aliases:
- /docs/oncall/latest/on-call-schedules/web-schedule/
canonical: https://grafana.com/docs/oncall/latest/on-call-schedules/web-schedule/
description: "Learn more about Grafana OnCalls built in schedule tool"
keywords:
@ -9,6 +7,7 @@ keywords:
- oncall
- schedule
- calendar
title: Web-based schedules
weight: 100
---

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/
title: Grafana OnCall HTTP API reference
weight: 1500

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/alertgroups/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/alertgroups/
title: Alert groups HTTP API
weight: 400

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/alerts/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/alerts/
title: Alerts HTTP API
weight: 100

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/escalation_chains/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_chains/
title: Escalation Chains HTTP API
weight: 200

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/escalation_policies/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation_policies/
title: Escalation Policies HTTP API
weight: 300

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/integrations/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/integrations/
title: Integrations HTTP API
weight: 500

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/on_call_shifts/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/on_call_shifts/
title: OnCall shifts HTTP API
weight: 600

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/outgoing_webhooks/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/outgoing_webhooks/
title: Outgoing webhooks HTTP API
weight: 700

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/personal_notification_rules/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/personal_notification_rules/
title: Personal Notification Rules HTTP API
weight: 800

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/postmortem_messages/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/postmortem_messages/
draft: true
title: Postmortem Messages HTTP API

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/postmortems/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/postmortems/
draft: true
title: Postmortem HTTP API

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/routes/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/routes/
title: Routes HTTP API
weight: 1100

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/schedules/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/schedules/
title: Schedule HTTP API
weight: 1200
@ -197,3 +195,171 @@ curl "{{API_URL}}/api/v1/schedules/SBM7DV7BKFUYU/" \
**HTTP request**
`DELETE {{API_URL}}/api/v1/schedules/<SCHEDULE_ID>/`
# Export a schedule's final shifts
**HTTP request**
```shell
curl "{{API_URL}}/api/v1/schedules/SBM7DV7BKFUYU/final_shifts?start_date=2023-01-01&end_date=2023-02-01" \
--request GET \
--header "Authorization: meowmeowmeow"
```
The above command returns JSON structured in the following way:
```json
{
"count": 12,
"next": null,
"previous": null,
"results": [
{
"user_pk": "UC2CHRT5SD34X",
"user_email": "alice@example.com",
"user_username": "alice",
"shift_start": "2023-01-02T09:00:00Z",
"shift_end": "2023-01-02T17:00:00Z"
},
{
"user_pk": "U7S8H84ARFTGN",
"user_email": "bob@example.com",
"user_username": "bob",
"shift_start": "2023-01-04T09:00:00Z",
"shift_end": "2023-01-04T17:00:00Z"
},
{
"user_pk": "UC2CHRT5SD34X",
"user_email": "alice@example.com",
"user_username": "alice",
"shift_start": "2023-01-06T09:00:00Z",
"shift_end": "2023-01-06T17:00:00Z"
},
{
"user_pk": "U7S8H84ARFTGN",
"user_email": "bob@example.com",
"user_username": "bob",
"shift_start": "2023-01-09T09:00:00Z",
"shift_end": "2023-01-09T17:00:00Z"
},
{
"user_pk": "UC2CHRT5SD34X",
"user_email": "alice@example.com",
"user_username": "alice",
"shift_start": "2023-01-11T09:00:00Z",
"shift_end": "2023-01-11T17:00:00Z"
},
{
"user_pk": "U7S8H84ARFTGN",
"user_email": "bob@example.com",
"user_username": "bob",
"shift_start": "2023-01-13T09:00:00Z",
"shift_end": "2023-01-13T17:00:00Z"
},
{
"user_pk": "UC2CHRT5SD34X",
"user_email": "alice@example.com",
"user_username": "alice",
"shift_start": "2023-01-16T09:00:00Z",
"shift_end": "2023-01-16T17:00:00Z"
},
{
"user_pk": "U7S8H84ARFTGN",
"user_email": "bob@example.com",
"user_username": "bob",
"shift_start": "2023-01-18T09:00:00Z",
"shift_end": "2023-01-18T17:00:00Z"
},
{
"user_pk": "UC2CHRT5SD34X",
"user_email": "alice@example.com",
"user_username": "alice",
"shift_start": "2023-01-20T09:00:00Z",
"shift_end": "2023-01-20T17:00:00Z"
},
{
"user_pk": "U7S8H84ARFTGN",
"user_email": "bob@example.com",
"user_username": "bob",
"shift_start": "2023-01-23T09:00:00Z",
"shift_end": "2023-01-23T17:00:00Z"
},
{
"user_pk": "UC2CHRT5SD34X",
"user_email": "alice@example.com",
"user_username": "alice",
"shift_start": "2023-01-25T09:00:00Z",
"shift_end": "2023-01-25T17:00:00Z"
},
{
"user_pk": "U7S8H84ARFTGN",
"user_email": "bob@example.com",
"user_username": "bob",
"shift_start": "2023-01-27T09:00:00Z",
"shift_end": "2023-01-27T17:00:00Z"
}
]
}
```
## Caveats
Some notes on the `start_date` and `end_date` query parameters:
- they are both required and should represent ISO 8601 formatted dates
- `end_date` must be greater than or equal to `start_date`
- `end_date` cannot be more than 365 days in the future from `start_date`
Lastly, this endpoint is currently only active for web schedules. It will return HTTP 400 for schedules
defined via Terraform or iCal.
## Example script to transform data to .csv for all of your schedules
The following Python script will generate a `.csv` file, `oncall-report-2023-01-01-to-2023-01-31.csv`. This file will
contain three columns, `user_pk`, `user_email`, and `hours_on_call`, which represents how many hours each user was
on call during the period starting January 1, 2023 to January 31, 2023 (inclusive).
```python
import csv
import requests
from datetime import datetime
# CUSTOMIZE THE FOLLOWING VARIABLES
START_DATE = "2023-01-01"
END_DATE = "2023-01-31"
OUTPUT_FILE_NAME = f"oncall-report-{START_DATE}-to-{END_DATE}.csv"
MY_ONCALL_API_BASE_URL = "https://oncall-prod-us-central-0.grafana.net/oncall/api/v1/schedules"
MY_ONCALL_API_KEY = "meowmeowwoofwoof"
headers = {"Authorization": MY_ONCALL_API_KEY}
schedule_ids = [schedule["id"] for schedule in requests.get(MY_ONCALL_API_BASE_URL, headers=headers).json()["results"]]
user_on_call_hours = {}
for schedule_id in schedule_ids:
response = requests.get(
f"{MY_ONCALL_API_BASE_URL}/{schedule_id}/final_shifts?start_date={START_DATE}&end_date={END_DATE}",
headers=headers)
for final_shift in response.json()["results"]:
user_pk = final_shift["user_pk"]
end = datetime.fromisoformat(final_shift["shift_end"])
start = datetime.fromisoformat(final_shift["shift_start"])
shift_time_in_seconds = (end - start).total_seconds()
shift_time_in_hours = shift_time_in_seconds / (60 * 60)
if user_pk in user_on_call_hours:
user_on_call_hours[user_pk]["hours_on_call"] += shift_time_in_hours
else:
user_on_call_hours[user_pk] = {
"email": final_shift["user_email"],
"hours_on_call": shift_time_in_hours,
}
with open(OUTPUT_FILE_NAME, "w") as fp:
csv_writer = csv.DictWriter(fp, ["user_pk", "user_email", "hours_on_call"])
csv_writer.writeheader()
for user_pk, user_info in user_on_call_hours.items():
csv_writer.writerow({
"user_pk": user_pk, "user_email": user_info["email"], "hours_on_call": user_info["hours_on_call"]})
```

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/slack_channels/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/slack_channels/
title: Slack Channels HTTP API
weight: 1300

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/user_groups/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/user_groups/
title: OnCall User Groups HTTP API
weight: 1400

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/oncall-api-reference/users/
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/users/
title: Grafana OnCall Users HTTP API
weight: 1500

View file

@ -1,6 +1,4 @@
---
aliases:
- /docs/oncall/latest/open-source/
keywords:
- Open Source
title: Open Source

View file

@ -1,7 +1,6 @@
---
aliases:
- ../outgoing-webhooks/
- /docs/oncall/latest/outgoing-webhooks/
canonical: https://grafana.com/docs/oncall/latest/outgoing-webhooks/
keywords:
- Grafana Cloud

View file

@ -1,7 +1,5 @@
---
title: User and team management
aliases:
- /docs/oncall/latest/user-and-team-management/
keywords:
- oncall
- RBAC

View file

@ -7,7 +7,9 @@ RUN apt-get update && apt-get install -y \
netcat \
curl \
bash \
git
git \
libpcre3 \
libpcre3-dev
WORKDIR /etc/app
COPY ./requirements.txt ./

View file

@ -195,6 +195,7 @@ class OnCallShiftUpdateSerializer(OnCallShiftSerializer):
validated_data = self._correct_validated_data(instance.type, validated_data)
change_only_title = True
create_or_update_last_shift = False
force_update = validated_data.pop("force_update", True)
for field in validated_data:
if field != "title" and validated_data[field] != getattr(instance, field):
@ -209,7 +210,7 @@ class OnCallShiftUpdateSerializer(OnCallShiftSerializer):
elif instance.event_is_finished:
raise serializers.ValidationError(["This event cannot be updated"])
if create_or_update_last_shift:
if not force_update and create_or_update_last_shift:
result = instance.create_or_update_last_shift(validated_data)
else:
result = super().update(instance, validated_data)

View file

@ -402,6 +402,56 @@ def test_update_started_on_call_shift(
assert on_call_shift.until == on_call_shift.updated_shift.rotation_start
@pytest.mark.django_db
def test_update_started_on_call_shift_force_update(
on_call_shift_internal_api_setup,
make_on_call_shift,
make_user_auth_headers,
):
token, user1, _, _, schedule = on_call_shift_internal_api_setup
client = APIClient()
start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0)
title = "Test Shift Rotation"
on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
start=start_date,
duration=timezone.timedelta(hours=3),
rotation_start=start_date,
rolling_users=[{user1.pk: user1.public_primary_key}],
)
data_to_update = {
"title": title,
"priority_level": 2,
"shift_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"shift_end": (start_date + timezone.timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"rotation_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"until": None,
"frequency": None,
"interval": None,
"by_day": None,
"rolling_users": [[user1.public_primary_key]],
}
assert on_call_shift.priority_level != data_to_update["priority_level"]
url = reverse("api-internal:oncall_shifts-detail", kwargs={"pk": on_call_shift.public_primary_key}) + "?force=true"
response = client.put(url, data=data_to_update, format="json", **make_user_auth_headers(user1, token))
assert response.status_code == status.HTTP_200_OK
# check no shift was created
assert response.data["id"] == on_call_shift.public_primary_key
on_call_shift.refresh_from_db()
assert on_call_shift.priority_level == data_to_update["priority_level"]
assert on_call_shift.updated_shift is None
assert on_call_shift.until is None
@pytest.mark.django_db
def test_update_old_on_call_shift_with_future_version(
on_call_shift_internal_api_setup,
@ -1363,7 +1413,9 @@ def test_on_call_shift_preview(
"is_gap": False,
"priority_level": 2,
"missing_users": [],
"users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}],
"users": [
{"display_name": other_user.username, "pk": other_user.public_primary_key, "email": other_user.email}
],
"source": "web",
}
]
@ -1653,7 +1705,9 @@ def test_on_call_shift_preview_update(
"is_gap": False,
"priority_level": 1,
"missing_users": [],
"users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}],
"users": [
{"display_name": other_user.username, "pk": other_user.public_primary_key, "email": other_user.email}
],
"source": "web",
}
assert rotation_events[-1] == expected_shift_preview
@ -1764,7 +1818,9 @@ def test_on_call_shift_preview_update_not_started_reuse_pk(
"is_gap": False,
"priority_level": 1,
"missing_users": [],
"users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}],
"users": [
{"display_name": other_user.username, "pk": other_user.public_primary_key, "email": other_user.email}
],
"source": "web",
},
]

View file

@ -812,7 +812,7 @@ def test_events_calendar(
"all_day": False,
"start": on_call_shift.start,
"end": on_call_shift.start + on_call_shift.duration,
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
"users": [{"display_name": user.username, "pk": user.public_primary_key, "email": user.email}],
"missing_users": [],
"priority_level": on_call_shift.priority_level,
"source": "api",
@ -878,7 +878,7 @@ def test_filter_events_calendar(
"all_day": False,
"start": mon_start,
"end": mon_start + on_call_shift.duration,
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
"users": [{"display_name": user.username, "pk": user.public_primary_key, "email": user.email}],
"missing_users": [],
"priority_level": on_call_shift.priority_level,
"source": "api",
@ -894,7 +894,7 @@ def test_filter_events_calendar(
"all_day": False,
"start": fri_start,
"end": fri_start + on_call_shift.duration,
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
"users": [{"display_name": user.username, "pk": user.public_primary_key, "email": user.email}],
"missing_users": [],
"priority_level": on_call_shift.priority_level,
"source": "api",
@ -977,7 +977,7 @@ def test_filter_events_range_calendar(
"all_day": False,
"start": fri_start,
"end": fri_start + on_call_shift.duration,
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
"users": [{"display_name": user.username, "pk": user.public_primary_key, "email": user.email}],
"missing_users": [],
"priority_level": on_call_shift.priority_level,
"source": "api",
@ -1059,7 +1059,13 @@ def test_filter_events_overrides(
"all_day": False,
"start": override_start,
"end": override_start + override.duration,
"users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}],
"users": [
{
"display_name": other_user.username,
"pk": other_user.public_primary_key,
"email": other_user.email,
}
],
"missing_users": [],
"priority_level": None,
"source": "api",

View file

@ -71,7 +71,8 @@ class OnCallShiftView(TeamFilteringMixin, PublicPrimaryKeyMixin, UpdateSerialize
def perform_update(self, serializer):
prev_state = serializer.instance.insight_logs_serialized
serializer.save()
force_update = self.request.query_params.get("force", "") == "true"
serializer.save(force_update=force_update)
new_state = serializer.instance.insight_logs_serialized
write_resource_insight_log(
instance=serializer.instance,

View file

@ -71,3 +71,17 @@ class ScheduleBaseSerializer(serializers.ModelSerializer):
}
return result
class FinalShiftQueryParamsSerializer(serializers.Serializer):
start_date = serializers.DateField(required=True)
end_date = serializers.DateField(required=True)
def validate(self, attrs):
if attrs["start_date"] > attrs["end_date"]:
raise serializers.ValidationError("start_date must be less than or equal to end_date")
if attrs["end_date"] - attrs["start_date"] > timezone.timedelta(days=365):
raise serializers.ValidationError(
"The difference between start_date and end_date must be less than one year (365 days)"
)
return attrs

View file

@ -1,3 +1,4 @@
import collections
from unittest.mock import patch
import pytest
@ -781,3 +782,146 @@ def test_create_ical_schedule_without_ical_url(make_organization_and_user_with_t
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_oncall_shifts_request_validation(
make_organization_and_user_with_token,
make_schedule,
):
organization, _, token = make_organization_and_user_with_token()
ical_schedule = make_schedule(organization, schedule_class=OnCallScheduleICal)
terraform_schedule = make_schedule(organization, schedule_class=OnCallScheduleCalendar)
web_schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
schedule_type_validation_msg = "OnCall shifts exports are currently only available for web calendars"
valid_date_msg = "Date has wrong format. Use one of these formats instead: YYYY-MM-DD."
client = APIClient()
def _make_request(schedule, query_params=""):
url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key})
return client.get(f"{url}{query_params}", format="json", HTTP_AUTHORIZATION=token)
# only web schedules are allowed for now
response = _make_request(ical_schedule)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data == schedule_type_validation_msg
response = _make_request(terraform_schedule)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data == schedule_type_validation_msg
# query param validation
response = _make_request(web_schedule, "?start_date=2021-01-01")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["end_date"][0] == "This field is required."
response = _make_request(web_schedule, "?start_date=asdfasdf")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["start_date"][0] == valid_date_msg
response = _make_request(web_schedule, "?end_date=2021-01-01")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["start_date"][0] == "This field is required."
response = _make_request(web_schedule, "?start_date=2021-01-01&end_date=asdfasdf")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["end_date"][0] == valid_date_msg
response = _make_request(web_schedule, "?end_date=2021-01-01&start_date=2022-01-01")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"non_field_errors": [
"start_date must be less than or equal to end_date",
]
}
response = _make_request(web_schedule, "?end_date=2021-01-01&start_date=2019-12-31")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"non_field_errors": [
"The difference between start_date and end_date must be less than one year (365 days)",
]
}
@pytest.mark.django_db
def test_oncall_shifts_export(
make_organization_and_user_with_token,
make_user,
make_schedule,
make_on_call_shift,
):
organization, _, token = make_organization_and_user_with_token()
user1_email = "alice909450945045@example.com"
user2_email = "bob123123123123123@example.com"
user1_username = "alice"
user2_username = "bob"
user1 = make_user(organization=organization, email=user1_email, username=user1_username)
user2 = make_user(organization=organization, email=user2_email, username=user2_username)
user1_public_primary_key = user1.public_primary_key
user2_public_primary_key = user2.public_primary_key
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
start_date = timezone.datetime(2023, 1, 1, 9, 0, 0)
make_on_call_shift(
organization=organization,
schedule=schedule,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
frequency=CustomOnCallShift.FREQUENCY_DAILY,
priority_level=1,
interval=1,
by_day=["MO", "WE", "FR"],
start=start_date,
until=start_date + timezone.timedelta(days=28),
rolling_users=[{user1.pk: user1_public_primary_key}, {user2.pk: user2_public_primary_key}],
rotation_start=start_date,
duration=timezone.timedelta(hours=8),
)
client = APIClient()
url = reverse("api-public:schedules-final-shifts", kwargs={"pk": schedule.public_primary_key})
response = client.get(f"{url}?start_date=2023-01-01&end_date=2023-02-01", format="json", HTTP_AUTHORIZATION=token)
response_json = response.json()
shifts = response_json["results"]
total_time_on_call = collections.defaultdict(int)
pk_to_user_mapping = {
user1_public_primary_key: {
"email": user1_email,
"username": user1_username,
},
user2_public_primary_key: {
"email": user2_email,
"username": user2_username,
},
}
for row in shifts:
user_pk = row["user_pk"]
# make sure we're exporting email and username as well
assert pk_to_user_mapping[user_pk]["email"] == row["user_email"]
assert pk_to_user_mapping[user_pk]["username"] == row["user_username"]
end = timezone.datetime.fromisoformat(row["shift_end"])
start = timezone.datetime.fromisoformat(row["shift_start"])
shift_time_in_seconds = (end - start).total_seconds()
total_time_on_call[row["user_pk"]] += shift_time_in_seconds / (60 * 60)
assert response.status_code == status.HTTP_200_OK
# 3 shifts per week x 4 weeks x 8 hours per shift = 96 / 2 users = 48h per user for this period
expected_time_on_call = 48
assert total_time_on_call[user1_public_primary_key] == expected_time_on_call
assert total_time_on_call[user2_public_primary_key] == expected_time_on_call
# pagination parameters are mocked out for now
assert response_json["next"] is None
assert response_json["previous"] is None
assert response_json["count"] == len(shifts)

View file

@ -1,3 +1,5 @@
import logging
from django_filters import rest_framework as filters
from rest_framework import status
from rest_framework.decorators import action
@ -9,9 +11,11 @@ from rest_framework.viewsets import ModelViewSet
from apps.auth_token.auth import ApiTokenAuthentication, ScheduleExportAuthentication
from apps.public_api.custom_renderers import CalendarRenderer
from apps.public_api.serializers import PolymorphicScheduleSerializer, PolymorphicScheduleUpdateSerializer
from apps.public_api.serializers.schedules_base import FinalShiftQueryParamsSerializer
from apps.public_api.throttlers.user_throttle import UserThrottle
from apps.schedules.ical_utils import ical_export_from_schedule
from apps.schedules.models import OnCallSchedule, OnCallScheduleWeb
from apps.schedules.models.on_call_schedule import ScheduleEvents, ScheduleFinalShifts
from apps.slack.tasks import update_slack_user_group_for_schedules
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamFilter
@ -19,6 +23,8 @@ from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMix
from common.api_helpers.paginators import FiftyPageSizePaginator
from common.insight_log import EntityEvent, write_resource_insight_log
logger = logging.getLogger(__name__)
class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet):
authentication_classes = (ApiTokenAuthentication,)
@ -120,3 +126,51 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo
# Not using existing get_object method because it requires access to the organization user attribute
export = ical_export_from_schedule(self.request.auth.schedule)
return Response(export, status=status.HTTP_200_OK)
@action(methods=["get"], detail=True)
def final_shifts(self, request, pk):
schedule = self.get_object()
if not isinstance(schedule, OnCallScheduleWeb):
return Response(
"OnCall shifts exports are currently only available for web calendars",
status=status.HTTP_400_BAD_REQUEST,
)
serializer = FinalShiftQueryParamsSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
start_date = serializer.validated_data["start_date"]
end_date = serializer.validated_data["end_date"]
days_between_start_and_end = (end_date - start_date).days
final_schedule_events: ScheduleEvents = schedule.final_events("UTC", start_date, days_between_start_and_end)
logger.info(
f"Exporting oncall shifts for schedule {pk} between dates {start_date} and {end_date}. {len(final_schedule_events)} shift events were found."
)
data: ScheduleFinalShifts = [
{
"user_pk": user["pk"],
"user_email": user["email"],
"user_username": user["display_name"],
"shift_start": event["start"],
"shift_end": event["end"],
}
for event in final_schedule_events
for user in event["users"]
]
# right now we'll "mock out" the pagination related parameters (next and previous)
# rather than use a Pagination class from drf (as currently it operates on querysets). We've decided on this
# to make this response schema consistent with the rest of the public API + make it easy to add pagination
# here in the future (should we decide to migrate "final_shifts" to an actual model)
return Response(
{
"count": len(data),
"next": None,
"previous": None,
"results": data,
}
)

View file

@ -76,6 +76,7 @@ class QualityReport(TypedDict):
class ScheduleEventUser(TypedDict):
display_name: str
pk: str
email: str
class ScheduleEventShift(TypedDict):
@ -97,8 +98,17 @@ class ScheduleEvent(TypedDict):
shift: ScheduleEventShift
class ScheduleFinalShift(TypedDict):
user_pk: str
user_email: str
user_username: str
shift_start: str
shift_end: str
ScheduleEvents = List[ScheduleEvent]
ScheduleEventIntervals = List[List[datetime.datetime]]
ScheduleFinalShifts = List[ScheduleFinalShift]
def generate_public_primary_key_for_oncall_schedule_channel():
@ -323,6 +333,7 @@ class OnCallSchedule(PolymorphicModel):
"users": [
{
"display_name": user.username,
"email": user.email,
"pk": user.public_primary_key,
}
for user in shift["users"]

View file

@ -93,7 +93,7 @@ def test_filter_events(make_organization, make_user_for_organization, make_sched
"is_gap": False,
"priority_level": on_call_shift.priority_level,
"missing_users": [],
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
"users": [{"display_name": user.username, "pk": user.public_primary_key, "email": user.email}],
"shift": {"pk": on_call_shift.public_primary_key},
"source": "api",
}
@ -114,7 +114,7 @@ def test_filter_events(make_organization, make_user_for_organization, make_sched
"is_gap": False,
"priority_level": None,
"missing_users": [],
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
"users": [{"display_name": user.username, "pk": user.public_primary_key, "email": user.email}],
"shift": {"pk": override.public_primary_key},
"source": "api",
}
@ -179,7 +179,7 @@ def test_filter_events_include_gaps(make_organization, make_user_for_organizatio
"is_gap": False,
"priority_level": on_call_shift.priority_level,
"missing_users": [],
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
"users": [{"display_name": user.username, "pk": user.public_primary_key, "email": user.email}],
"shift": {"pk": on_call_shift.public_primary_key},
"source": "api",
},
@ -688,7 +688,9 @@ def test_preview_shift(make_organization, make_user_for_organization, make_sched
"is_gap": False,
"priority_level": new_shift.priority_level,
"missing_users": [],
"users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}],
"users": [
{"display_name": other_user.username, "pk": other_user.public_primary_key, "email": other_user.email}
],
"shift": {"pk": new_shift.public_primary_key},
"source": "api",
}
@ -846,7 +848,9 @@ def test_preview_override_shift(make_organization, make_user_for_organization, m
"is_gap": False,
"priority_level": None,
"missing_users": [],
"users": [{"display_name": other_user.username, "pk": other_user.public_primary_key}],
"users": [
{"display_name": other_user.username, "pk": other_user.public_primary_key, "email": other_user.email}
],
"shift": {"pk": new_shift.public_primary_key},
"source": "api",
}

View file

@ -3,9 +3,8 @@ import logging
from django.apps import apps
from django.conf import settings
from django.core.exceptions import PermissionDenied, RequestDataTooBig
from django.core.exceptions import PermissionDenied
from django.db import OperationalError
from django.http import HttpResponse
from django.utils.deprecation import MiddlewareMixin
logger = logging.getLogger(__name__)
@ -57,19 +56,6 @@ class RequestTimeLoggingMiddleware(MiddlewareMixin):
return response
class RequestBodyReadingMiddleware(MiddlewareMixin):
def process_request(self, request):
# Reading request body, as required by uwsgi
# https://uwsgi-docs.readthedocs.io/en/latest/ThingsToKnow.html
# "If an HTTP request has a body (like a POST request generated by a form),
# you have to read (consume) it in your application.
# If you do not do this, the communication socket with your webserver may be clobbered."
try:
request.body
except RequestDataTooBig:
return HttpResponse(status=400)
class BanAlertConsumptionBasedOnSettingsMiddleware(MiddlewareMixin):
"""
Banning requests for /integrations/v1

View file

@ -245,7 +245,6 @@ MIDDLEWARE = [
"log_request_id.middleware.RequestIDMiddleware",
"engine.middlewares.RequestTimeLoggingMiddleware",
"engine.middlewares.BanAlertConsumptionBasedOnSettingsMiddleware",
"engine.middlewares.RequestBodyReadingMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"debug_toolbar.middleware.DebugToolbarMiddleware",

View file

@ -17,6 +17,9 @@ http-timeout=620
post-buffering=1
enable-threads=true
; drop requests with CONTENT_LENGTH bigger than 15MB
route-if=ishigher:${CONTENT_LENGTH};15000000 break:413 Request Entity Too Large
logger=stdio
log-format=source=engine:uwsgi status=%(status) method=%(method) path=%(uri) latency=%(secs) google_trace_id=%(var.HTTP_X_CLOUD_TRACE_CONTEXT) protocol=%(proto) resp_size=%(size) req_body_size=%(cl)
log-encoder=format ${strftime:%%Y-%%m-%%d %%H:%%M:%%S} ${msgnl}

View file

@ -166,7 +166,7 @@ class LiveSettings extends React.Component<LiveSettingsProps, LiveSettingsState>
onTextChange={this.getEditValueChangeHandler(item)}
editable={isUserActionAllowed(UserActions.OtherSettingsWrite)}
clearBeforeEdit={item.is_secret}
hidden={hideValues}
hidden={hideValues && item.is_secret}
>
{normalizeValue(item.value)}
</Text>
@ -208,7 +208,7 @@ class LiveSettings extends React.Component<LiveSettingsProps, LiveSettingsState>
return (
<div style={{ wordWrap: 'break-word', wordBreak: 'break-word' }}>
<Text>{hideValues ? PLACEHOLDER : normalizeValue(item.default_value)}</Text>
<Text>{hideValues && item.is_secret ? PLACEHOLDER : normalizeValue(item.default_value)}</Text>
</div>
);
};