From 4cec8eefc21460c7821eab746457f3b64f292bf3 Mon Sep 17 00:00:00 2001 From: Richard Hartmann Date: Fri, 3 Jun 2022 16:19:05 +0200 Subject: [PATCH 001/132] Add Grafana Labs governance and maintainers Signed-off-by: Richard Hartmann --- GOVERNANCE.md | 164 +++++++++++++++++++++++++++++++++++++++++++++++++ MAINTAINERS.md | 15 +++++ 2 files changed, 179 insertions(+) create mode 100644 GOVERNANCE.md create mode 100644 MAINTAINERS.md diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 00000000..830980c8 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,164 @@ +--- +title: Governance +--- + +# Governance + +This document describes the rules and governance of the project. It is meant to be followed by all the developers of the project and the OnCall community. Common terminology used in this governance document are listed below: + +- **Team members**: Any members of the private [team mailing list][team]. + +- **Maintainers**: Maintainers lead an individual project or parts thereof ([`MAINTAINERS.md`][maintainers]). + +- **Projects**: A single repository in the Grafana GitHub organization and listed below is referred to as a project: + + - oncall + +- **The OnCall project**: The sum of all activities performed under this governance, concerning one or more repositories or the community. + +## Values + +The OnCall developers and community are expected to follow the values defined in the [Code of Conduct][coc]. Furthermore, the OnCall community strives for kindness, giving feedback effectively, and building a welcoming environment. The OnCall developers generally decide by consensus and only resort to conflict resolution by a majority vote if consensus cannot be reached. + +## Projects + +Each project must have a [`MAINTAINERS.md`][maintainers] file with at least one maintainer. Where a project has a release process, access and documentation should be such that more than one person can perform a release. Releases should be announced on the [announcemount][announce] and [users][users] mailing lists. Any new projects should be first proposed on the [team mailing list][team] following the voting procedures listed below. + +## Decision making + +### Team members + +Team member status may be given to those who have made ongoing contributions to the OnCall project for at least 3 months. This is usually in the form of code improvements and/or notable work on documentation, but organizing events or user support could also be taken into account. + +New members may be proposed by any existing member by email to the [team mailing list][team]. It is highly desirable to reach consensus about acceptance of a new member. However, the proposal is ultimately voted on by a formal [supermajority vote](#supermajority-vote). + +If the new member proposal is accepted, the proposed team member should be contacted privately via email to confirm or deny their acceptance of team membership. This email will also be CC'd to the [team mailing list][team] for record-keeping purposes. + +If they choose to accept, the [onboarding](#onboarding) procedure is followed. + +Team members may retire at any time by emailing [the team][team]. + +Team members can be removed by [supermajority vote](#supermajority-vote) on [the team mailing list][team]. +For this vote, the member in question is not eligible to vote and does not count towards the quorum. +Any removal vote can cover only one single person. + +Upon death of a member, they leave the team automatically. + +In case a member leaves, the [offboarding](#offboarding) procedure is applied. + +The current team members are: + +- Eve Meelan — [@Eve832](https://github.com/Eve832) ([Grafana Labs](https://grafana.com/)) +- Ildar Iskhakov — [@iskhakov](https://github.com/iskhakov) ([Grafana Labs](https://grafana.com/)) +- Innokentii Konstantinov — [@Konstantinov-Innokentii](https://github.com/Konstantinov-Innokentii) ([Grafana Labs](https://grafana.com/)) +- Matías Bordese — [@matiasb](https://github.com/matiasb) ([Grafana Labs](https://grafana.com/)) +- Matvey Kuku — [@Matvey-Kuk](https://github.com/Matvey-Kuk) ([Grafana Labs](https://grafana.com/)) +- Michael Derynck — [@mderynck](https://github.com/mderynck) ([Grafana Labs](https://grafana.com/)) +- Vadim Stepanov — [@vadimkerr](https://github.com/vadimkerr) ([Grafana Labs](https://grafana.com/)) +- Yulia Shanyrova — [@Ukochka](https://github.com/Ukochka) ([Grafana Labs](https://grafana.com/)) + +Previous team members: + +- n/a + +### Maintainers + +Maintainers lead one or more project(s) or parts thereof and serve as a point of conflict resolution amongst the contributors to this project. Ideally, maintainers are also team members, but exceptions are possible for suitable maintainers that, for whatever reason, are not yet team members. + +Changes in maintainership have to be announced on the [developers mailing list][devs]. They are decided by [rough consensus](#consensus) and formalized by changing the [`MAINTAINERS.md`][maintainers] file of the respective repository. + +Maintainers are granted commit rights to all projects covered by this governance. + +A maintainer or committer may resign by notifying the [team mailing list][team]. A maintainer with no project activity for a year is considered to have resigned. Maintainers that wish to resign are encouraged to propose another team member to take over the project. + +A project may have multiple maintainers, as long as the responsibilities are clearly agreed upon between them. This includes coordinating who handles which issues and pull requests. + +### Technical decisions + +Technical decisions that only affect a single project are made informally by the maintainer of this project, and [rough consensus](#consensus) is assumed. Technical decisions that span multiple parts of the project should be discussed and made on the [developer mailing list][devs]. + +Decisions are usually made by [rough consensus](#consensus). If no consensus can be reached, the matter may be resolved by [majority vote](#majority-vote). + +### Governance changes + +Changes to this document are made by Grafana Labs. + +### Other matters + +Any matter that needs a decision may be called to a vote by any member if they deem it necessary. For private or personnel matters, discussion and voting takes place on the [team mailing list][team], otherwise on the [developer mailing list][devs]. + +## Voting + +The OnCall project usually runs by informal consensus, however sometimes a formal decision must be made. + +Depending on the subject matter, as laid out [above](#decision-making), different methods of voting are used. + +For all votes, voting must be open for at least one week. The end date should be clearly stated in the call to vote. A vote may be called and closed early if enough votes have come in one way so that further votes cannot change the final decision. + +In all cases, all and only [team members](#team-members) are eligible to vote, with the sole exception of the forced removal of a team member, in which said member is not eligible to vote. + +Discussion and votes on personnel matters (including but not limited to team membership and maintainership) are held in private on the [team mailing list][team]. All other discussion and votes are held in public on the [developer mailing list][devs]. + +For public discussions, anyone interested is encouraged to participate. Formal power to object or vote is limited to [team members](#team-members). + +### Consensus + +The default decision making mechanism for the OnCall project is [rough][rough] consensus. This means that any decision on technical issues is considered supported by the [team][team] as long as nobody objects or the objection has been considered but not necessarily accommodated. + +Silence on any consensus decision is implicit agreement and equivalent to explicit agreement. Explicit agreement may be stated at will. Decisions may, but do not need to be called out and put up for decision on the [developers mailing list][devs] at any time and by anyone. + +Consensus decisions can never override or go against the spirit of an earlier explicit vote. + +If any [team member](#team-members) raises objections, the team members work together towards a solution that all involved can accept. This solution is again subject to rough consensus. + +In case no consensus can be found, but a decision one way or the other must be made, any [team member](#team-members) may call a formal [majority vote](#majority-vote). + +### Majority vote + +Majority votes must be called explicitly in a separate thread on the appropriate mailing list. The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. It should reference any discussion leading up to this point. + +Votes may take the form of a single proposal, with the option to vote yes or no, or the form of multiple alternatives. + +A vote on a single proposal is considered successful if more vote in favor than against. + +If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to all alternatives. It is not possible to cast an “abstain” vote. A vote on multiple alternatives is considered decided in favor of one alternative if it has received the most votes in favor, and a vote from more than half of those voting. Should no alternative reach this quorum, another vote on a reduced number of options may be called separately. + +### Supermajority vote + +Supermajority votes must be called explicitly in a separate thread on the appropriate mailing list. The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. It should reference any discussion leading up to this point. + +Votes may take the form of a single proposal, with the option to vote yes or no, or the form of multiple alternatives. + +A vote on a single proposal is considered successful if at least two thirds of those eligible to vote vote in favor. + +If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to all alternatives. A vote on multiple alternatives is considered decided in favor of one alternative if it has received the most votes in favor, and a vote from at least two thirds of those eligible to vote. Should no alternative reach this quorum, another vote on a reduced number of options may be called separately. + +## On- / Offboarding + +### Onboarding + +The new member is + +- added to the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. +- announced on the [developers mailing list][devs] by an existing team member. Ideally, the new member replies in this thread, acknowledging team membership. +- added to the projects with commit rights. +- added to the [team mailing list][team]. + +### Offboarding + +The ex-member is + +- removed from the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. In case of forced removal, no approval is needed. +- removed from the projects. Optionally, they can retain maintainership of one or more repositories if the [team](#team-members) agrees. +- removed from the team mailing list and demoted to a normal member of the other mailing lists. +- not allowed to call themselves an active team member any more, nor allowed to imply this to be the case. +- added to a list of previous members if they so choose. + +If needed, we reserve the right to publicly announce removal. + +[announce]: TODO +[coc]: https://github.com/grafana/oncall/blob/master/CODE_OF_CONDUCT.md +[devs]: TODO +[maintainers]: https://github.com/grafana/oncall/blob/master/MAINTAINERS.md +[rough]: https://tools.ietf.org/html/rfc7282 +[team]: TODO diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 00000000..97c9ba33 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,15 @@ +The following are the main/default maintainers: + +- Ildar Iskhakov — [@iskhakov](https://github.com/iskhakov) ([Grafana Labs](https://grafana.com/)) +- Matvey Kuku — [@Matvey-Kuk](https://github.com/Matvey-Kuk) ([Grafana Labs](https://grafana.com/)) + +Some parts of the codebase have other maintainers, the package paths also include all sub-packages: + +- `docs`: + - Eve Meelan — [@Eve832](https://github.com/Eve832) ([Grafana Labs](https://grafana.com/)) + +For the sake of brevity, not all subtrees are explicitly listed. Due to the +size of this repository, the natural changes in focus of maintainers over time, +and nuances of where particular features live, this list will always be +incomplete and out of date. However the listed maintainer(s) should be able to +direct a PR/question to the right person. From 818be0a5f8b422bd47edd2bb26f081b8a1865620 Mon Sep 17 00:00:00 2001 From: Richard Hartmann Date: Fri, 3 Jun 2022 16:21:26 +0200 Subject: [PATCH 002/132] Making LICENSING.md more explicit Signed-off-by: Richard Hartmann --- LICENSING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/LICENSING.md b/LICENSING.md index 4e53ac0d..34951583 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -9,9 +9,11 @@ The default license for this project is [AGPL-3.0-only](LICENSE). The following directories and their subdirectories are licensed under Apache-2.0: ``` +n/a ``` The following directories and their subdirectories are licensed under their original upstream licenses: ``` +n/a ``` From 0cdd2d7b8b3f47ca74fbdbc4909a72c4c3373d0c Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Fri, 3 Jun 2022 19:47:25 +0400 Subject: [PATCH 003/132] First touch on grafana cloud notifications --- .../templaters/phone_call_templater.py | 8 +- engine/apps/oss_installation/cloud_sync.py | 0 engine/apps/oss_installation/constants.py | 1 + .../apps/oss_installation/models/__init__.py | 2 + .../models/cloud_organization_connector.py | 138 ++++++++++++++++++ .../oss_installation/models/cloud_users.py | 14 ++ .../models/oss_installation.py | 7 + engine/apps/public_api/urls.py | 2 + engine/apps/public_api/views/__init__.py | 1 + .../public_api/views/phone_notifications.py | 68 +++++++++ engine/apps/public_api/views/users.py | 4 + engine/apps/twilioapp/models/phone_call.py | 115 +++++++++------ engine/apps/twilioapp/models/sms_message.py | 119 ++++++++------- engine/common/utils.py | 8 + 14 files changed, 383 insertions(+), 104 deletions(-) create mode 100644 engine/apps/oss_installation/cloud_sync.py create mode 100644 engine/apps/oss_installation/constants.py create mode 100644 engine/apps/oss_installation/models/cloud_organization_connector.py create mode 100644 engine/apps/oss_installation/models/cloud_users.py create mode 100644 engine/apps/public_api/views/phone_notifications.py diff --git a/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py b/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py index 6f9997d7..3d0127ca 100644 --- a/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py +++ b/engine/apps/alerts/incident_appearance/templaters/phone_call_templater.py @@ -1,5 +1,5 @@ from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater -from common.utils import clean_markup +from common.utils import clean_markup, escape_for_twilio_phone_call class AlertPhoneCallTemplater(AlertTemplater): @@ -24,8 +24,4 @@ class AlertPhoneCallTemplater(AlertTemplater): return sf.format(data) def _escape(self, data): - # https://www.twilio.com/docs/api/errors/12100 - data = data.replace("&", "&") - data = data.replace(">", ">") - data = data.replace("<", "<") - return data + return escape_for_twilio_phone_call(data) diff --git a/engine/apps/oss_installation/cloud_sync.py b/engine/apps/oss_installation/cloud_sync.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/oss_installation/constants.py b/engine/apps/oss_installation/constants.py new file mode 100644 index 00000000..c6c3b88b --- /dev/null +++ b/engine/apps/oss_installation/constants.py @@ -0,0 +1 @@ +CLOUD_URL = "https://a-prod-us-central-0.grafana.net/" diff --git a/engine/apps/oss_installation/models/__init__.py b/engine/apps/oss_installation/models/__init__.py index 53dea35e..80721219 100644 --- a/engine/apps/oss_installation/models/__init__.py +++ b/engine/apps/oss_installation/models/__init__.py @@ -1,2 +1,4 @@ +from .cloud_organization_connector import CloudOrganizationConnector # noqa: F401 +from .cloud_users import CloudUserIdentity # noqa: F401 from .heartbeat import CloudHeartbeat # noqa: F401 from .oss_installation import OssInstallation # noqa: F401 diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_organization_connector.py new file mode 100644 index 00000000..36316457 --- /dev/null +++ b/engine/apps/oss_installation/models/cloud_organization_connector.py @@ -0,0 +1,138 @@ +import logging +from urllib.parse import urljoin + +import requests +from django.db import models + +from apps.base.utils import live_settings +from apps.oss_installation.constants import CLOUD_URL +from apps.oss_installation.models.cloud_users import CloudUserIdentity +from apps.user_management.models import User + +logger = logging.getLogger(__name__) + + +class CloudOrganizationConnector(models.Model): + """ + CloudOrganizationConnector model represents connection between oss organization and cloud organization. + """ + + cloud_url = models.URLField() + organization = models.OneToOneField( + "user_management.organization", related_name="cloud_connector", on_delete=models.CASCADE + ) + + @classmethod + def sync_with_cloud(cls, organization) -> bool: + """ + sync_with_cloud sync organization with cloud organization defined by provided GRAFANA_CLOUD_ONCALL_TOKEN. + """ + sync_status = False + + api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + if api_token is None: + logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") + else: + info_url = urljoin(CLOUD_URL, "api/v1/info/") + try: + r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5) + if r.status_code == 200: + cls.objects.update_or_create(organization=organization, defaults={"cloud_url": r.json()["url"]}) + sync_status = True + if r.status_code == 403: + logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid") + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to sync with cloud. Request exception {str(e)}") + return sync_status + + def sync_users_with_cloud(self): + api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + if api_token is None: + logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") + return + + existing_emails = list(User.objects.filter(organization=self.organization).values_list("email", flat=True)) + # existing_cloud_ids = list( + # CloudUserIdentity.objects.filter(organization=self.organization).values_list("cloud_id", flat=True) + # ) + matching_users = [] + users_url = urljoin(CLOUD_URL, "api/v1/users") + + existing_cloud_identities = list(CloudUserIdentity.objects.filter(organization=self.organization)) + existing_cloud_ids = list(map(lambda u: u.cloud_id, existing_cloud_identities)) + + fetch_next_page = True + page = 1 + while fetch_next_page: + try: + url = urljoin(users_url, f"?page={page}&?short=true") + r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5) + if r.status_code != 200: + logger.warning( + f"Unable to fetch page {page} while sync_users_with_cloud. Response status code {r.status_code}" + ) + if r.status_code == 429 or r.status_code == 403: + break + data = r.json() + matching_users.extend(list(filter(lambda u: (u["email"] in existing_emails), data["results"]))) + page += 1 + if data["next"] is None: + fetch_next_page = False + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to sync users with cloud. Request exception {str(e)}") + break + + cloud_users_identities_to_update = {} + + cloud_users_identities_to_create = [] + for user in matching_users: + if user["id"] in existing_cloud_ids: + cloud_users_identities_to_update[user["id"]] = user + else: + cloud_users_identities_to_create.append( + CloudUserIdentity( + cloud_id=user["id"], + email=user["email"], + phone_number_verified=user["is_phone_number_verified"], + organization=self.organization, + ) + ) + + for i in existing_cloud_identities: + i.email = cloud_users_identities_to_update[i.cloud_id]["email"] + i.phone_number_verified = cloud_users_identities_to_update[i.cloud_id]["is_phone_number_verified"] + + # TODO: Grafana Twilio: check if data validation needed. + CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) + CloudUserIdentity.objects.bulk_update( + existing_cloud_identities, ["email", "phone_number_verified"], batch_size=1000 + ) + + def sync_user_with_cloud(self, user): + api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + if api_token is None: + logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. GRAFANA_CLOUD_ONCALL_TOKEN is not set") + return + + url = urljoin(CLOUD_URL, f"api/v1/users/?email={user.email}") + try: + r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5) + if r.status_code != 200: + logger.warning( + f"Unable to sync_user_with_cloud user_id {user.id}. Response status code {r.status_code}" + ) + return + data = r.json() + if len(data["results"]) != 0: + cloud_used_data = data["results"][0] + CloudUserIdentity.objects.update_or_create( + email=user.email, + defaults={ + "phone_number_verified": cloud_used_data["is_phone_number_verified"], + "cloud_id": cloud_used_data["id"], + }, + ) + else: + logger.warning(f"Unable to sync_user_with_cloud user_id {user.id}. User with {user.email} not found") + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. Request exception {str(e)}") diff --git a/engine/apps/oss_installation/models/cloud_users.py b/engine/apps/oss_installation/models/cloud_users.py new file mode 100644 index 00000000..cbef16c0 --- /dev/null +++ b/engine/apps/oss_installation/models/cloud_users.py @@ -0,0 +1,14 @@ +from django.db import models + + +class CloudUserIdentity(models.Model): + phone_number_verified = models.BooleanField(default=False) + cloud_id = models.CharField(max_length=20) + email = models.EmailField() + organization = models.ForeignKey( + "user_management.Organization", on_delete=models.CASCADE, related_name="cloud_users" + ) + + class Meta: + # TODO: Grafana Twilio: Check if this constraint needed + unique_together = ("cloud_id", "organization") diff --git a/engine/apps/oss_installation/models/oss_installation.py b/engine/apps/oss_installation/models/oss_installation.py index 9e4dd3dd..2e553fcf 100644 --- a/engine/apps/oss_installation/models/oss_installation.py +++ b/engine/apps/oss_installation/models/oss_installation.py @@ -1,9 +1,16 @@ +import logging import uuid from django.db import models +logger = logging.getLogger(__name__) + class OssInstallation(models.Model): + """ + OssInstallation is model to track installation of OSS OnCall version. + """ + installation_id = models.UUIDField(default=uuid.uuid4, editable=False) created_at = models.DateTimeField(auto_now=True) report_sent_at = models.DateTimeField(null=True, default=None) diff --git a/engine/apps/public_api/urls.py b/engine/apps/public_api/urls.py index 95fa447a..a91898df 100644 --- a/engine/apps/public_api/urls.py +++ b/engine/apps/public_api/urls.py @@ -30,4 +30,6 @@ router.register(r"teams", views.TeamView, basename="teams") urlpatterns = [ path("", include(router.urls)), optional_slash_path("info", views.InfoView.as_view(), name="info"), + optional_slash_path("make_call", views.MakeCallView.as_view(), name="make_call"), + optional_slash_path("send_sms", views.SendSMSView.as_view(), name="send_sms"), ] diff --git a/engine/apps/public_api/views/__init__.py b/engine/apps/public_api/views/__init__.py index 1892d123..4ffcec04 100644 --- a/engine/apps/public_api/views/__init__.py +++ b/engine/apps/public_api/views/__init__.py @@ -8,6 +8,7 @@ from .integrations import IntegrationView # noqa: F401 from .on_call_shifts import CustomOnCallShiftView # noqa: F401 from .organizations import OrganizationView # noqa: F401 from .personal_notifications import PersonalNotificationView # noqa: F401 +from .phone_notifications import MakeCallView, SendSMSView # noqa: F401 from .resolution_notes import ResolutionNoteView # noqa: F401 from .routes import ChannelFilterView # noqa: F401 from .schedules import OnCallScheduleChannelView # noqa: F401 diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py new file mode 100644 index 00000000..eed301a2 --- /dev/null +++ b/engine/apps/public_api/views/phone_notifications.py @@ -0,0 +1,68 @@ +# TODO: move to serializers +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from twilio.base.exceptions import TwilioRestException + +from apps.auth_token.auth import ApiTokenAuthentication +from apps.twilioapp.models import PhoneCall, SMSMessage + + +class PhoneNotificationDataSerializer(serializers.Serializer): + email = serializers.EmailField() + message = serializers.CharField(max_length=200) + + +class MakeCallView(APIView): + authentication_classes = (ApiTokenAuthentication,) + permission_classes = (IsAuthenticated,) + + # TODO: add ratelimit + + def post(self, request): + # TODO: Grafana Twilio: + # 1. Validate user's email + # 2. Validate payload: clean_markup, escape_for_twilio_phone_call + # 3. Create LogRecord (User notification policy or implement new one) + serializer = PhoneNotificationDataSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + organization = self.request.auth.organization + # TODO: filter by verified phone number? + user = organization.users.filter(email=serializer.validated_data["email"]).first() + if not user or not user.verified_phone_number: + return Response(status=status.HTTP_400_BAD_REQUEST) + + try: + PhoneCall.make_grafana_cloud_call(user, serializer.validated_data["message"]) + except TwilioRestException: + return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE) + except PhoneCall.PhoneCallsLimitExceeded: + return Response(status=status.HTTP_400_BAD_REQUEST) + + return Response(status=status.HTTP_200_OK) + + +class SendSMSView(APIView): + authentication_classes = (ApiTokenAuthentication,) + permission_classes = (IsAuthenticated,) + + def post(self, request): + serializer = PhoneNotificationDataSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + organization = self.request.auth.organization + # TODO: filter by verified phone number? + user = organization.users.filter(email=serializer.validated_data["email"]).first() + if not user or not user.verified_phone_number: + return Response(status=status.HTTP_400_BAD_REQUEST) + + try: + SMSMessage.send_cloud_sms(user, serializer.validated_data["message"]) + except TwilioRestException: + return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE) + except SMSMessage.SMSLimitExceeded: + return Response(status=status.HTTP_400_BAD_REQUEST) + + return Response(status=status.HTTP_200_OK) diff --git a/engine/apps/public_api/views/users.py b/engine/apps/public_api/views/users.py index 815c6553..99a32a85 100644 --- a/engine/apps/public_api/views/users.py +++ b/engine/apps/public_api/views/users.py @@ -33,12 +33,16 @@ class UserView(RateLimitHeadersMixin, ShortSerializerMixin, DemoTokenMixin, Read def get_queryset(self): username = self.request.query_params.get("username") + email = self.request.query_params.get("email") is_short_request = self.request.query_params.get("short", "false") == "true" queryset = self.request.auth.organization.users.filter(role__in=[Role.ADMIN, Role.EDITOR]).distinct() if username is not None: queryset = queryset.filter(username=username) + if email is not None: + queryset = queryset.filter(email=email) + if not is_short_request: queryset = self.serializer_class.setup_eager_loading(queryset) return queryset.order_by("id") diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py index 7d5ae0f9..2cfe44b4 100644 --- a/engine/apps/twilioapp/models/phone_call.py +++ b/engine/apps/twilioapp/models/phone_call.py @@ -34,8 +34,10 @@ class PhoneCallManager(models.Manager): if phone_call_qs.exists() and status: phone_call_qs.update(status=status) - phone_call = phone_call_qs.first() + if phone_call.grafana_cloud_notification: + # If call was made via grafana twilio it is don't needed to create logs on it's delivery status. + return log_record = None if status == TwilioCallStatuses.COMPLETED: log_record = UserNotificationPolicyLogRecord( @@ -115,6 +117,14 @@ class PhoneCall(models.Model): created_at = models.DateTimeField(auto_now_add=True) + grafana_cloud_notification = models.BooleanField(default=False) + + class PhoneCallsLimitExceeded(Exception): + """Phone calls limit exceeded""" + + class PhoneNumberNotVerifiedError(Exception): + """Phone number is not verified""" + def process_digit(self, digit): """The function process pressed digit at time of call to user @@ -140,55 +150,32 @@ class PhoneCall(models.Model): @classmethod def make_call(cls, user, alert_group, notification_policy): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - - organization = alert_group.channel.organization - log_record = None - if user.verified_phone_number: - # Create a PhoneCall object in db - phone_call = PhoneCall( - represents_alert_group=alert_group, - receiver=user, + renderer = AlertGroupPhoneCallRenderer(alert_group) + message_body = renderer.render() + try: + cls._make_call(user, message_body, alert_group=alert_group, notification_policy=notification_policy) + except TwilioRestException: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, ) - - phone_calls_left = organization.phone_calls_left(user) - - if phone_calls_left > 0: - phone_call.exceeded_limit = False - renderer = AlertGroupPhoneCallRenderer(alert_group) - message_body = renderer.render() - if phone_calls_left < 3: - message_body += " {} phone calls left. Contact your admin.".format(phone_calls_left) - try: - twilio_call = twilio_client.make_call(message_body, user.verified_phone_number) - except TwilioRestException: - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_CALL, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - else: - if twilio_call.status and twilio_call.sid: - phone_call.status = TwilioCallStatuses.DETERMINANT.get(twilio_call.status, None) - phone_call.sid = twilio_call.sid - else: - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - phone_call.exceeded_limit = True - phone_call.save() - else: + except PhoneCall.PhoneCallsLimitExceeded: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, + ) + except PhoneCall.PhoneNumberNotVerifiedError: log_record = UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, @@ -203,6 +190,40 @@ class PhoneCall(models.Model): log_record.save() user_notification_action_triggered_signal.send(sender=PhoneCall.make_call, log_record=log_record) + @classmethod + def make_grafana_cloud_call(cls, user, message_body): + cls._make_call(user, message_body, grafana_cloud=True) + + @classmethod + def _make_call(cls, user, message_body, alert_group=None, notification_policy=None, grafana_cloud=False): + if not user.verified_phone_number: + raise PhoneCall.PhoneNumberNotVerifiedError("User phone number is not verified") + + phone_call = PhoneCall( + represents_alert_group=alert_group, + receiver=user, + notification_policy=notification_policy, + grafana_cloud_notification=grafana_cloud, + ) + phone_calls_left = user.organization.phone_calls_left(user) + + if phone_calls_left <= 0: + phone_call.exceeded_limit = True + phone_call.save() + raise PhoneCall.PhoneCallsLimitExceeded("Organization calls limit exceeded") + + phone_call.exceeded_limit = False + if phone_calls_left < 3: + message_body += " {} phone calls left. Contact your admin.".format(phone_calls_left) + + twilio_call = twilio_client.make_call(message_body, user.verified_phone_number) + if twilio_call.status and twilio_call.sid: + phone_call.status = TwilioCallStatuses.DETERMINANT.get(twilio_call.status, None) + phone_call.sid = twilio_call.sid + phone_call.save() + + return phone_call + @staticmethod def get_error_code_by_twilio_status(status): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py index 09404e56..4046f464 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -36,7 +36,9 @@ class SMSMessageManager(models.Manager): sms_message_qs.update(status=status) sms_message = sms_message_qs.first() - + if sms_message.grafana_cloud_notification: + # If sms was sent via grafana twilio it is don't needed to create logs on it's delivery status. + return log_record = None if status == TwilioMessageStatuses.DELIVERED: @@ -90,6 +92,7 @@ class SMSMessage(models.Model): null=True, choices=TwilioMessageStatuses.CHOICES, ) + grafana_cloud_notification = models.BooleanField(default=False) # https://www.twilio.com/docs/sms/api/message-resource#message-properties sid = models.CharField( @@ -99,6 +102,12 @@ class SMSMessage(models.Model): created_at = models.DateTimeField(auto_now_add=True) + class SMSLimitExceeded(Exception): + """SMS limit exceeded""" + + class PhoneNumberNotVerifiedError(Exception): + """Phone number is not verified""" + @property def created_for_slack(self): return bool(self.represents_alert_group.slack_message) @@ -107,58 +116,32 @@ class SMSMessage(models.Model): def send_sms(cls, user, alert_group, notification_policy): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - organization = alert_group.channel.organization - log_record = None - if user.verified_phone_number: - # Create an SMS object in db - sms_message = SMSMessage( - represents_alert_group=alert_group, receiver=user, notification_policy=notification_policy + renderer = AlertGroupSmsRenderer(alert_group) + message_body = renderer.render() + try: + cls._send_sms(user, message_body, alert_group=alert_group, notification_policy=notification_policy) + except TwilioRestException: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, ) - - sms_left = organization.sms_left(user) - if sms_left > 0: - # Mark is as successfully sent - sms_message.exceeded_limit = False - # Render alert message for sms - renderer = AlertGroupSmsRenderer(alert_group) - message_body = renderer.render() - # Notify if close to limit - if sms_left < 3: - message_body += " {} sms left. Contact your admin.".format(sms_left) - # Send an sms - try: - twilio_message = twilio_client.send_message(message_body, user.verified_phone_number) - except TwilioRestException: - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_NOT_ABLE_TO_SEND_SMS, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - else: - if twilio_message.status and twilio_message.sid: - sms_message.status = TwilioMessageStatuses.DETERMINANT.get(twilio_message.status, None) - sms_message.sid = twilio_message.sid - else: - # If no more sms left, mark as exceeded limit - log_record = UserNotificationPolicyLogRecord( - author=user, - type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, - notification_policy=notification_policy, - alert_group=alert_group, - notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED, - notification_step=notification_policy.step if notification_policy else None, - notification_channel=notification_policy.notify_by if notification_policy else None, - ) - sms_message.exceeded_limit = True - - # Save object - sms_message.save() - else: + except SMSMessage.SMSLimitExceeded: + log_record = UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + alert_group=alert_group, + notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_policy.notify_by if notification_policy else None, + ) + except SMSMessage.PhoneNumberNotVerifiedError: log_record = UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, @@ -173,6 +156,40 @@ class SMSMessage(models.Model): log_record.save() user_notification_action_triggered_signal.send(sender=SMSMessage.send_sms, log_record=log_record) + @classmethod + def send_grafana_cloud_sms(cls, user, message_body): + cls._send_sms(user, message_body, grafana_cloud=True) + + @classmethod + def _send_sms(cls, user, message_body, alert_group=None, notification_policy=None, grafana_cloud=False): + if not user.verified_phone_number: + raise SMSMessage.PhoneNumberNotVerifiedError("User phone number is not verified") + + sms_message = SMSMessage( + represents_alert_group=alert_group, + receiver=user, + notification_policy=notification_policy, + grafana_cloud_notification=grafana_cloud, + ) + sms_left = user.organization.sms_left(user) + + if sms_left <= 0: + sms_message.exceeded_limit = True + sms_message.save() + raise SMSMessage.SMSLimitExceeded("Organization sms limit exceeded") + + sms_message.exceeded_limit = False + if sms_left < 3: + message_body += " {} sms left. Contact your admin.".format(sms_left) + + twilio_message = twilio_client.send_message(message_body, user.verified_phone_number) + if twilio_message.status and twilio_message.sid: + sms_message.status = TwilioMessageStatuses.DETERMINANT.get(twilio_message.status, None) + sms_message.sid = twilio_message.sid + sms_message.save() + + return sms_message + @staticmethod def get_error_code_by_twilio_status(status): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") diff --git a/engine/common/utils.py b/engine/common/utils.py index 4b9ef9c1..7507bf97 100644 --- a/engine/common/utils.py +++ b/engine/common/utils.py @@ -177,6 +177,14 @@ def clean_markup(text): return cleaned +def escape_for_twilio_phone_call(text): + # https://www.twilio.com/docs/api/errors/12100 + text = text.replace("&", "&") + text = text.replace(">", ">") + text = text.replace("<", "<") + return text + + def escape_html(text): return html.escape(text) From f68d3f214664cb49ec687f59ee30cbc8f25ca69c Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Fri, 3 Jun 2022 14:59:43 -0300 Subject: [PATCH 004/132] Plug in sms/phone cloud notifications --- engine/apps/alerts/tasks/notify_user.py | 15 ++++++- engine/apps/base/models/live_setting.py | 2 + .../public_api/views/phone_notifications.py | 43 ++++++++++--------- engine/apps/twilioapp/models/phone_call.py | 37 ++++++++++++++-- engine/apps/twilioapp/models/sms_message.py | 37 ++++++++++++++-- engine/settings/base.py | 1 + 6 files changed, 106 insertions(+), 29 deletions(-) diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index 05a9456f..e2c8cb23 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -12,6 +12,7 @@ from apps.alerts.constants import NEXT_ESCALATION_DELAY from apps.alerts.incident_appearance.renderers.web_renderer import AlertGroupWebRenderer from apps.alerts.signals import user_notification_action_triggered_signal from apps.base.messaging import get_messaging_backend_from_id +from apps.base.utils import live_settings from common.custom_celery_tasks import shared_dedicated_queue_retry_task from .task_logger import task_logger @@ -258,10 +259,20 @@ def perform_notification(log_record_pk): return if notification_channel == UserNotificationPolicy.NotificationChannel.SMS: - SMSMessage.send_sms(user, alert_group, notification_policy) + SMSMessage.send_sms( + user, + alert_group, + notification_policy, + is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, + ) elif notification_channel == UserNotificationPolicy.NotificationChannel.PHONE_CALL: - PhoneCall.make_call(user, alert_group, notification_policy) + PhoneCall.make_call( + user, + alert_group, + notification_policy, + is_cloud_notification=live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, + ) elif notification_channel == UserNotificationPolicy.NotificationChannel.TELEGRAM: if alert_group.notify_in_telegram_enabled is True: diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index c08ab11f..1c0b806a 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -44,6 +44,7 @@ class LiveSetting(models.Model): "SEND_ANONYMOUS_USAGE_STATS", "GRAFANA_CLOUD_ONCALL_TOKEN", "GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", + "GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", ) DESCRIPTIONS = { @@ -106,6 +107,7 @@ class LiveSetting(models.Model): ), "GRAFANA_CLOUD_ONCALL_TOKEN": "Secret token for Grafana Cloud OnCall instance.", "GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED": "Enable hearbeat integration with Grafana Cloud OnCall.", + "GRAFANA_CLOUD_NOTIFICATIONS_ENABLED": "Enable SMS/call notifications via Grafana Cloud OnCall", } SECRET_SETTING_NAMES = ( diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py index eed301a2..5269d4a9 100644 --- a/engine/apps/public_api/views/phone_notifications.py +++ b/engine/apps/public_api/views/phone_notifications.py @@ -1,4 +1,3 @@ -# TODO: move to serializers from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -11,7 +10,7 @@ from apps.twilioapp.models import PhoneCall, SMSMessage class PhoneNotificationDataSerializer(serializers.Serializer): email = serializers.EmailField() - message = serializers.CharField(max_length=200) + message = serializers.CharField(max_length=1024) class MakeCallView(APIView): @@ -21,27 +20,26 @@ class MakeCallView(APIView): # TODO: add ratelimit def post(self, request): - # TODO: Grafana Twilio: - # 1. Validate user's email - # 2. Validate payload: clean_markup, escape_for_twilio_phone_call - # 3. Create LogRecord (User notification policy or implement new one) serializer = PhoneNotificationDataSerializer(data=request.data) serializer.is_valid(raise_exception=True) + response_data = {} organization = self.request.auth.organization - # TODO: filter by verified phone number? - user = organization.users.filter(email=serializer.validated_data["email"]).first() - if not user or not user.verified_phone_number: - return Response(status=status.HTTP_400_BAD_REQUEST) + user = organization.users.filter( + email=serializer.validated_data["email"], _verified_phone_number__isnull=False + ).first() + if user is None: + response_data = {"error": "user-not-found"} + return Response(status=status.HTTP_404_NOT_FOUND, data=response_data) try: PhoneCall.make_grafana_cloud_call(user, serializer.validated_data["message"]) except TwilioRestException: - return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE) + return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data) except PhoneCall.PhoneCallsLimitExceeded: - return Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"}) - return Response(status=status.HTTP_200_OK) + return Response(status=status.HTTP_200_OK, data=response_data) class SendSMSView(APIView): @@ -52,17 +50,20 @@ class SendSMSView(APIView): serializer = PhoneNotificationDataSerializer(data=request.data) serializer.is_valid(raise_exception=True) + response_data = {} organization = self.request.auth.organization - # TODO: filter by verified phone number? - user = organization.users.filter(email=serializer.validated_data["email"]).first() - if not user or not user.verified_phone_number: - return Response(status=status.HTTP_400_BAD_REQUEST) + user = organization.users.filter( + email=serializer.validated_data["email"], _verified_phone_number__isnull=False + ).first() + if user is None: + response_data = {"error": "user-not-found"} + return Response(status=status.HTTP_404_NOT_FOUND, data=response_data) try: - SMSMessage.send_cloud_sms(user, serializer.validated_data["message"]) + SMSMessage.send_grafana_cloud_sms(user, serializer.validated_data["message"]) except TwilioRestException: - return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE) + return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data) except SMSMessage.SMSLimitExceeded: - return Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"}) - return Response(status=status.HTTP_200_OK) + return Response(status=status.HTTP_200_OK, data=response_data) diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py index 2cfe44b4..72389811 100644 --- a/engine/apps/twilioapp/models/phone_call.py +++ b/engine/apps/twilioapp/models/phone_call.py @@ -1,7 +1,11 @@ import logging +from urllib.parse import urljoin +import requests from django.apps import apps +from django.conf import settings from django.db import models +from rest_framework import status from twilio.base.exceptions import TwilioRestException from apps.alerts.constants import ActionSource @@ -125,6 +129,9 @@ class PhoneCall(models.Model): class PhoneNumberNotVerifiedError(Exception): """Phone number is not verified""" + class CloudSendError(Exception): + """Error making call through cloud""" + def process_digit(self, digit): """The function process pressed digit at time of call to user @@ -148,14 +155,38 @@ class PhoneCall(models.Model): return bool(self.represents_alert_group.slack_message) @classmethod - def make_call(cls, user, alert_group, notification_policy): + def _make_cloud_call(cls, user, message_body): + url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/make_call") + auth = {"Authorization": settings.GRAFANA_CLOUD_ONCALL_TOKEN} + data = { + "email": user.email, + "message": message_body, + } + try: + response = requests.post(url, headers=auth, data=data, timeout=5) + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to make call through cloud. Request exception {str(e)}") + raise PhoneCall.CloudSendError("Unable to make call through cloud: request failed") + + if response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded": + raise PhoneCall.PhoneCallsLimitExceeded("Organization calls limit exceeded") + elif response.status_code == status.HTTP_404_NOT_FOUND: + raise PhoneCall.CloudSendError("Unable to make call through cloud: user not found") + else: + raise PhoneCall.CloudSendError("Unable to make call through cloud: server error") + + @classmethod + def make_call(cls, user, alert_group, notification_policy, is_cloud_notification=False): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") log_record = None renderer = AlertGroupPhoneCallRenderer(alert_group) message_body = renderer.render() try: - cls._make_call(user, message_body, alert_group=alert_group, notification_policy=notification_policy) - except TwilioRestException: + if is_cloud_notification: + cls._make_cloud_call(user, message_body) + else: + cls._make_call(user, message_body, alert_group=alert_group, notification_policy=notification_policy) + except (TwilioRestException, PhoneCall.CloudSendError): log_record = UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py index 4046f464..433419e5 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -1,7 +1,11 @@ import logging +from urllib.parse import urljoin +import requests from django.apps import apps +from django.conf import settings from django.db import models +from rest_framework import status from twilio.base.exceptions import TwilioRestException from apps.alerts.incident_appearance.renderers.sms_renderer import AlertGroupSmsRenderer @@ -108,20 +112,47 @@ class SMSMessage(models.Model): class PhoneNumberNotVerifiedError(Exception): """Phone number is not verified""" + class CloudSendError(Exception): + """SMS sending through cloud error""" + @property def created_for_slack(self): return bool(self.represents_alert_group.slack_message) @classmethod - def send_sms(cls, user, alert_group, notification_policy): + def _send_cloud_sms(cls, user, message_body): + url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/send_sms") + auth = {"Authorization": settings.GRAFANA_CLOUD_ONCALL_TOKEN} + data = { + "email": user.email, + "message": message_body, + } + try: + response = requests.post(url, headers=auth, data=data, timeout=5) + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to send SMS through cloud. Request exception {str(e)}") + raise SMSMessage.CloudSendError("Unable to send SMS through cloud: request failed") + + if response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded": + raise SMSMessage.SMSLimitExceeded("Organization sms limit exceeded") + elif response.status_code == status.HTTP_404_NOT_FOUND: + raise SMSMessage.CloudSendError("Unable to send SMS through cloud: user not found") + else: + raise SMSMessage.CloudSendError("Unable to send SMS through cloud: server error") + + @classmethod + def send_sms(cls, user, alert_group, notification_policy, is_cloud_notification=False): UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") log_record = None renderer = AlertGroupSmsRenderer(alert_group) message_body = renderer.render() try: - cls._send_sms(user, message_body, alert_group=alert_group, notification_policy=notification_policy) - except TwilioRestException: + if is_cloud_notification: + cls._send_cloud_sms(user, message_body) + else: + cls._send_sms(user, message_body, alert_group=alert_group, notification_policy=notification_policy) + except (TwilioRestException, SMSMessage.CloudSendError): log_record = UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, diff --git a/engine/settings/base.py b/engine/settings/base.py index b2150a47..9bb227f9 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -412,6 +412,7 @@ SELF_HOSTED_SETTINGS = { GRAFANA_CLOUD_ONCALL_API_URL = os.environ.get("GRAFANA_CLOUD_ONCALL_API_URL", "https://a-prod-us-central-0.grafana.net") GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None) GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) +GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) GRAFANA_INCIDENT_STATIC_API_KEY = os.environ.get("GRAFANA_INCIDENT_STATIC_API_KEY", None) From 5e494531eb93b52a2d1be98fcc25531509146eaa Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Fri, 3 Jun 2022 23:03:54 +0400 Subject: [PATCH 005/132] Add CloudUsersView --- .../models/cloud_organization_connector.py | 2 +- .../oss_installation/models/cloud_users.py | 2 +- engine/apps/oss_installation/urls.py | 3 +- .../apps/oss_installation/views/__init__.py | 1 + .../oss_installation/views/cloud_users.py | 53 +++++++++++++++++++ engine/apps/twilioapp/models/sms_message.py | 2 +- 6 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 engine/apps/oss_installation/views/cloud_users.py diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_organization_connector.py index 36316457..a142ddcb 100644 --- a/engine/apps/oss_installation/models/cloud_organization_connector.py +++ b/engine/apps/oss_installation/models/cloud_organization_connector.py @@ -102,7 +102,7 @@ class CloudOrganizationConnector(models.Model): i.email = cloud_users_identities_to_update[i.cloud_id]["email"] i.phone_number_verified = cloud_users_identities_to_update[i.cloud_id]["is_phone_number_verified"] - # TODO: Grafana Twilio: check if data validation needed. + # TODO: Grafana CN: check if data validation needed. CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) CloudUserIdentity.objects.bulk_update( existing_cloud_identities, ["email", "phone_number_verified"], batch_size=1000 diff --git a/engine/apps/oss_installation/models/cloud_users.py b/engine/apps/oss_installation/models/cloud_users.py index cbef16c0..5eb87f91 100644 --- a/engine/apps/oss_installation/models/cloud_users.py +++ b/engine/apps/oss_installation/models/cloud_users.py @@ -10,5 +10,5 @@ class CloudUserIdentity(models.Model): ) class Meta: - # TODO: Grafana Twilio: Check if this constraint needed + # TODO: Grafana CN: Check if this constraint needed unique_together = ("cloud_id", "organization") diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 956ffe74..cfa876e2 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -1,7 +1,8 @@ from common.api_helpers.optional_slash_router import optional_slash_path -from .views import CloudHeartbeatStatusView +from .views import CloudHeartbeatStatusView, CloudUsersView urlpatterns = [ optional_slash_path("cloud_heartbeat_status", CloudHeartbeatStatusView.as_view(), name="cloud_heartbeat_status"), + optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud_users"), ] diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 0716482b..98caf343 100644 --- a/engine/apps/oss_installation/views/__init__.py +++ b/engine/apps/oss_installation/views/__init__.py @@ -1 +1,2 @@ from .cloud_heartbeat_status import CloudHeartbeatStatusView # noqa: F401 +from .cloud_users import CloudUsersView # noqa: F401 diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py new file mode 100644 index 00000000..a1f93343 --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -0,0 +1,53 @@ +from urllib.parse import urljoin + +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import APIView + +from apps.auth_token.auth import PluginAuthentication +from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.user_management.models import User +from common.api_helpers.paginators import HundredPageSizePaginator + + +class CloudUsersView(HundredPageSizePaginator, APIView): + authentication_classes = (PluginAuthentication,) + # TODO: Grafana CN - permissions, ratelimit + permission_classes = (IsAuthenticated,) + + def get(self, request): + queryset = User.objects.filter(organization=self.request.user.organization) + + if self.request.user.current_team is not None: + queryset = queryset.filter(teams=self.request.user.current_team).distinct() + + results = self.paginate_queryset(queryset, request, view=self) + + emails = list(queryset.values_list("email", flat=True)) + cloud_identities = list( + CloudUserIdentity.objects.filter(organization=self.request.user.organization, email__in=emails) + ) + cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} + + response = [] + + connector = CloudOrganizationConnector.objects.first() + + for user in results: + cloud_identity = cloud_identities.get(user.email, None) + link = None + status = 0 + if cloud_identity: + status = 1 + is_phone_verified = cloud_identity.phone_number_verified + if is_phone_verified: + status = 2 + link = urljoin( + connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_identity.cloud_id}" + ) + + # TODO: Grafana CN - decide if emails is needed. If yes - don't forget to check that they mustn't be shown to users + response.append( + {"id": user.public_primary_key, "username": user.username, "cloud_sync_status": status, "link": link} + ) + + return self.get_paginated_response(response) diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py index 433419e5..c18dd7e8 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -41,7 +41,7 @@ class SMSMessageManager(models.Manager): sms_message = sms_message_qs.first() if sms_message.grafana_cloud_notification: - # If sms was sent via grafana twilio it is don't needed to create logs on it's delivery status. + # If sms was sent via grafana cloud notifications don't create logs on its delivery status. return log_record = None From 75f319fb5d0ac650ae4057ba79612ff54e069aa4 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Sat, 4 Jun 2022 16:49:10 +0400 Subject: [PATCH 006/132] Add CloudUsersView and CloudUserView --- engine/apps/api/views/features.py | 5 ++ engine/apps/base/utils.py | 17 ++++++ engine/apps/oss_installation/constants.py | 5 ++ .../apps/oss_installation/models/__init__.py | 2 +- .../models/cloud_organization_connector.py | 5 +- ...{cloud_users.py => cloud_user_identity.py} | 0 engine/apps/oss_installation/urls.py | 14 ++++- .../apps/oss_installation/views/cloud_user.py | 61 +++++++++++++++++++ .../oss_installation/views/cloud_users.py | 36 ++++++----- 9 files changed, 125 insertions(+), 20 deletions(-) rename engine/apps/oss_installation/models/{cloud_users.py => cloud_user_identity.py} (100%) create mode 100644 engine/apps/oss_installation/views/cloud_user.py diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index 6a4285de..79ed373b 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -4,11 +4,13 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.auth_token.auth import PluginAuthentication +from apps.base.utils import live_settings FEATURE_SLACK = "slack" FEATURE_TELEGRAM = "telegram" FEATURE_LIVE_SETTINGS = "live_settings" MOBILE_APP_PUSH_NOTIFICATIONS = "mobile_app" +FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications" class FeaturesAPIView(APIView): @@ -34,6 +36,9 @@ class FeaturesAPIView(APIView): if settings.FEATURE_LIVE_SETTINGS_ENABLED: enabled_features.append(FEATURE_LIVE_SETTINGS) + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + enabled_features.append(FEATURE_GRAFANA_CLOUD_NOTIFICATIONS) + if settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED: DynamicSetting = apps.get_model("base", "DynamicSetting") mobile_app_settings = DynamicSetting.objects.get_or_create( diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index 7342d00e..8ea5801e 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -1,7 +1,10 @@ import json import re +from urllib.parse import urljoin +import requests.exceptions from django.apps import apps +from django.conf import settings from python_http_client import UnauthorizedError from sendgrid import SendGridAPIClient from telegram import Bot @@ -94,6 +97,20 @@ class LiveSettingValidator: except Exception as e: return f"Telegram error: {str(e)}" + @classmethod + def _check_grafana_cloud_oncall_token(cls, grafan_oncall_token): + try: + info_url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") + r = requests.get(info_url, headers={"AUTHORIZATION": grafan_oncall_token}, timeout=5) + if r.status_code == 200: + return + elif r.status_code == 403: + return f"Invalid token." + else: + return f"Non-200 HTTP code. Got {r.status_code}" + except requests.exceptions.RequestException as e: + return f"Error {str(e)}" + @staticmethod def _is_email_valid(email): return re.match(r"^[^@]+@[^@]+\.[^@]+$", email) diff --git a/engine/apps/oss_installation/constants.py b/engine/apps/oss_installation/constants.py index c6c3b88b..db777bb3 100644 --- a/engine/apps/oss_installation/constants.py +++ b/engine/apps/oss_installation/constants.py @@ -1 +1,6 @@ CLOUD_URL = "https://a-prod-us-central-0.grafana.net/" + +CLOUD_NOT_SYNCED = 0 +CLOUD_SYNCED_USER_NOT_FOUND = 1 +CLOUD_SYNCED_PHONE_NOT_VERIFIED = 2 +CLOUD_SYNCED_PHONE_VERIFIED = 3 diff --git a/engine/apps/oss_installation/models/__init__.py b/engine/apps/oss_installation/models/__init__.py index 80721219..2ee74128 100644 --- a/engine/apps/oss_installation/models/__init__.py +++ b/engine/apps/oss_installation/models/__init__.py @@ -1,4 +1,4 @@ from .cloud_organization_connector import CloudOrganizationConnector # noqa: F401 -from .cloud_users import CloudUserIdentity # noqa: F401 +from .cloud_user_identity import CloudUserIdentity # noqa: F401 from .heartbeat import CloudHeartbeat # noqa: F401 from .oss_installation import OssInstallation # noqa: F401 diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_organization_connector.py index a142ddcb..732be38e 100644 --- a/engine/apps/oss_installation/models/cloud_organization_connector.py +++ b/engine/apps/oss_installation/models/cloud_organization_connector.py @@ -6,7 +6,7 @@ from django.db import models from apps.base.utils import live_settings from apps.oss_installation.constants import CLOUD_URL -from apps.oss_installation.models.cloud_users import CloudUserIdentity +from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity from apps.user_management.models import User logger = logging.getLogger(__name__) @@ -59,7 +59,7 @@ class CloudOrganizationConnector(models.Model): users_url = urljoin(CLOUD_URL, "api/v1/users") existing_cloud_identities = list(CloudUserIdentity.objects.filter(organization=self.organization)) - existing_cloud_ids = list(map(lambda u: u.cloud_id, existing_cloud_identities)) + existing_cloud_ids = list(map(lambda identity: identity.cloud_id, existing_cloud_identities)) fetch_next_page = True page = 1 @@ -102,7 +102,6 @@ class CloudOrganizationConnector(models.Model): i.email = cloud_users_identities_to_update[i.cloud_id]["email"] i.phone_number_verified = cloud_users_identities_to_update[i.cloud_id]["is_phone_number_verified"] - # TODO: Grafana CN: check if data validation needed. CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) CloudUserIdentity.objects.bulk_update( existing_cloud_identities, ["email", "phone_number_verified"], batch_size=1000 diff --git a/engine/apps/oss_installation/models/cloud_users.py b/engine/apps/oss_installation/models/cloud_user_identity.py similarity index 100% rename from engine/apps/oss_installation/models/cloud_users.py rename to engine/apps/oss_installation/models/cloud_user_identity.py diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index cfa876e2..86bb640b 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -1,8 +1,20 @@ +from django.urls import path + from common.api_helpers.optional_slash_router import optional_slash_path from .views import CloudHeartbeatStatusView, CloudUsersView +from .views.cloud_user import CloudUserView urlpatterns = [ optional_slash_path("cloud_heartbeat_status", CloudHeartbeatStatusView.as_view(), name="cloud_heartbeat_status"), - optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud_users"), + optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud-users-list"), + path( + "cloud_users/", + CloudUserView.as_view( + { + "get": "retrieve", + } + ), + name="cloud-user-detail", + ), ] diff --git a/engine/apps/oss_installation/views/cloud_user.py b/engine/apps/oss_installation/views/cloud_user.py new file mode 100644 index 00000000..5f5805cb --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_user.py @@ -0,0 +1,61 @@ +from urllib.parse import urljoin + +from rest_framework import mixins, serializers, viewsets +from rest_framework.permissions import IsAuthenticated + +import apps.oss_installation.constants as cloud_constants +from apps.api.permissions import ActionPermission, IsOwnerOrAdmin +from apps.auth_token.auth import PluginAuthentication +from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.user_management.models import User +from common.api_helpers.mixins import PublicPrimaryKeyMixin + + +class CloudUserSerializer(serializers.ModelSerializer): + cloud_data = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ["sync_data"] + + def get_cloud_data(self, obj): + link = None + status = cloud_constants.CLOUD_NOT_SYNCED + connector = CloudOrganizationConnector.objects.filter( + organization=self.context["request"].auth.organization + ).first() + if connector is not None: + cloud_user_identity = CloudUserIdentity.objects.filter(email=obj.email).first() + if cloud_user_identity is None: + status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND + link = connector.cloud_url + elif not cloud_user_identity.phone_number_verified: + status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND + link = urljoin( + connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_user_identity.cloud_id}" + ) + else: + status = cloud_constants.CLOUD_SYNCED_PHONE_VERIFIED + link = urljoin( + connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_user_identity.cloud_id}" + ) + cloud_data = {"status": status, "link": link} + return cloud_data + + +class CloudUserView( + PublicPrimaryKeyMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, ActionPermission) + + action_object_permissions = { + IsOwnerOrAdmin: ("retrieve",), + } + serializer_class = CloudUserSerializer + + def get_queryset(self): + queryset = User.objects.filter(organization=self.request.user.organization) + return queryset diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index a1f93343..af3a5cd8 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -3,6 +3,8 @@ from urllib.parse import urljoin from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView +import apps.oss_installation.constants as cloud_constants +from apps.api.permissions import IsAdmin from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity from apps.user_management.models import User @@ -11,8 +13,7 @@ from common.api_helpers.paginators import HundredPageSizePaginator class CloudUsersView(HundredPageSizePaginator, APIView): authentication_classes = (PluginAuthentication,) - # TODO: Grafana CN - permissions, ratelimit - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, IsAdmin) def get(self, request): queryset = User.objects.filter(organization=self.request.user.organization) @@ -31,23 +32,28 @@ class CloudUsersView(HundredPageSizePaginator, APIView): response = [] connector = CloudOrganizationConnector.objects.first() - for user in results: - cloud_identity = cloud_identities.get(user.email, None) link = None - status = 0 - if cloud_identity: - status = 1 - is_phone_verified = cloud_identity.phone_number_verified - if is_phone_verified: - status = 2 - link = urljoin( - connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_identity.cloud_id}" - ) + status = cloud_constants.CLOUD_NOT_SYNCED + if connector is not None: + status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND + cloud_identity = cloud_identities.get(user.email, None) + if cloud_identity: + status = cloud_constants.CLOUD_SYNCED_PHONE_NOT_VERIFIED + is_phone_verified = cloud_identity.phone_number_verified + if is_phone_verified: + status = cloud_constants.CLOUD_SYNCED_PHONE_VERIFIED + link = urljoin( + connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_identity.cloud_id}" + ) - # TODO: Grafana CN - decide if emails is needed. If yes - don't forget to check that they mustn't be shown to users response.append( - {"id": user.public_primary_key, "username": user.username, "cloud_sync_status": status, "link": link} + { + "id": user.public_primary_key, + "email": user.email, + "username": user.username, + "cloud_data": {"status": status, "link": link}, + } ) return self.get_paginated_response(response) From d182d4c305c72bd3500279af03431e607ebe9821 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Sun, 5 Jun 2022 13:15:17 +0400 Subject: [PATCH 007/132] Simplify sync with cloud system --- .../models/cloud_organization_connector.py | 38 ++++++------------- .../models/cloud_user_identity.py | 3 +- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_organization_connector.py index 732be38e..c4037895 100644 --- a/engine/apps/oss_installation/models/cloud_organization_connector.py +++ b/engine/apps/oss_installation/models/cloud_organization_connector.py @@ -2,7 +2,7 @@ import logging from urllib.parse import urljoin import requests -from django.db import models +from django.db import models, transaction from apps.base.utils import live_settings from apps.oss_installation.constants import CLOUD_URL @@ -52,15 +52,9 @@ class CloudOrganizationConnector(models.Model): return existing_emails = list(User.objects.filter(organization=self.organization).values_list("email", flat=True)) - # existing_cloud_ids = list( - # CloudUserIdentity.objects.filter(organization=self.organization).values_list("cloud_id", flat=True) - # ) matching_users = [] users_url = urljoin(CLOUD_URL, "api/v1/users") - existing_cloud_identities = list(CloudUserIdentity.objects.filter(organization=self.organization)) - existing_cloud_ids = list(map(lambda identity: identity.cloud_id, existing_cloud_identities)) - fetch_next_page = True page = 1 while fetch_next_page: @@ -82,13 +76,9 @@ class CloudOrganizationConnector(models.Model): logger.warning(f"Unable to sync users with cloud. Request exception {str(e)}") break - cloud_users_identities_to_update = {} - - cloud_users_identities_to_create = [] - for user in matching_users: - if user["id"] in existing_cloud_ids: - cloud_users_identities_to_update[user["id"]] = user - else: + with transaction.atomic(): + cloud_users_identities_to_create = [] + for user in matching_users: cloud_users_identities_to_create.append( CloudUserIdentity( cloud_id=user["id"], @@ -98,14 +88,8 @@ class CloudOrganizationConnector(models.Model): ) ) - for i in existing_cloud_identities: - i.email = cloud_users_identities_to_update[i.cloud_id]["email"] - i.phone_number_verified = cloud_users_identities_to_update[i.cloud_id]["is_phone_number_verified"] - - CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) - CloudUserIdentity.objects.bulk_update( - existing_cloud_identities, ["email", "phone_number_verified"], batch_size=1000 - ) + CloudUserIdentity.objects.filter(organization=self.organization).delete() + CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) def sync_user_with_cloud(self, user): api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN @@ -124,12 +108,12 @@ class CloudOrganizationConnector(models.Model): data = r.json() if len(data["results"]) != 0: cloud_used_data = data["results"][0] - CloudUserIdentity.objects.update_or_create( + CloudUserIdentity.objects.filter(email=user.emai).delete() + CloudUserIdentity.objects.create( email=user.email, - defaults={ - "phone_number_verified": cloud_used_data["is_phone_number_verified"], - "cloud_id": cloud_used_data["id"], - }, + organization=user.organization, + phone_number_verified=cloud_used_data["is_phone_number_verified"], + cloud_id=cloud_used_data["id"], ) else: logger.warning(f"Unable to sync_user_with_cloud user_id {user.id}. User with {user.email} not found") diff --git a/engine/apps/oss_installation/models/cloud_user_identity.py b/engine/apps/oss_installation/models/cloud_user_identity.py index 5eb87f91..fca0eebe 100644 --- a/engine/apps/oss_installation/models/cloud_user_identity.py +++ b/engine/apps/oss_installation/models/cloud_user_identity.py @@ -10,5 +10,4 @@ class CloudUserIdentity(models.Model): ) class Meta: - # TODO: Grafana CN: Check if this constraint needed - unique_together = ("cloud_id", "organization") + unique_together = ("email", "organization") From 4ef1ba9eb59ddd0ec39f21e9c80803c9afa88393 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Sun, 5 Jun 2022 19:09:40 +0400 Subject: [PATCH 008/132] Add views --- engine/apps/base/utils.py | 2 +- .../models/cloud_organization_connector.py | 112 +++++++++++------- .../oss_installation/serializers/__init__.py | 1 + .../{views => serializers}/cloud_user.py | 24 +--- engine/apps/oss_installation/urls.py | 4 +- .../apps/oss_installation/views/__init__.py | 3 +- .../oss_installation/views/cloud_status.py | 19 +++ .../oss_installation/views/cloud_users.py | 60 ++++++++-- 8 files changed, 148 insertions(+), 77 deletions(-) create mode 100644 engine/apps/oss_installation/serializers/__init__.py rename engine/apps/oss_installation/{views => serializers}/cloud_user.py (66%) create mode 100644 engine/apps/oss_installation/views/cloud_status.py diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index 8ea5801e..0f9f04b7 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -105,7 +105,7 @@ class LiveSettingValidator: if r.status_code == 200: return elif r.status_code == 403: - return f"Invalid token." + return f"Invalid token" else: return f"Non-200 HTTP code. Got {r.status_code}" except requests.exceptions.RequestException as e: diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_organization_connector.py index c4037895..126d9b65 100644 --- a/engine/apps/oss_installation/models/cloud_organization_connector.py +++ b/engine/apps/oss_installation/models/cloud_organization_connector.py @@ -23,15 +23,17 @@ class CloudOrganizationConnector(models.Model): ) @classmethod - def sync_with_cloud(cls, organization) -> bool: + def sync_with_cloud(cls, organization): """ sync_with_cloud sync organization with cloud organization defined by provided GRAFANA_CLOUD_ONCALL_TOKEN. """ sync_status = False + error_msg = None api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN if api_token is None: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") + error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" else: info_url = urljoin(CLOUD_URL, "api/v1/info/") try: @@ -39,23 +41,31 @@ class CloudOrganizationConnector(models.Model): if r.status_code == 200: cls.objects.update_or_create(organization=organization, defaults={"cloud_url": r.json()["url"]}) sync_status = True - if r.status_code == 403: + elif r.status_code == 403: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid") + error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is invalid" + else: + error_msg = f"Non-200 HTTP code. Got {r.status_code}" except requests.exceptions.RequestException as e: logger.warning(f"Unable to sync with cloud. Request exception {str(e)}") - return sync_status + error_msg = f"Unable to sync with cloud" + return sync_status, error_msg + + def sync_users_with_cloud(self) -> tuple[bool, str]: + sync_status = False + error_msg = None - def sync_users_with_cloud(self): api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN if api_token is None: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") - return + error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" existing_emails = list(User.objects.filter(organization=self.organization).values_list("email", flat=True)) matching_users = [] users_url = urljoin(CLOUD_URL, "api/v1/users") fetch_next_page = True + users_fetched = True page = 1 while fetch_next_page: try: @@ -65,8 +75,9 @@ class CloudOrganizationConnector(models.Model): logger.warning( f"Unable to fetch page {page} while sync_users_with_cloud. Response status code {r.status_code}" ) - if r.status_code == 429 or r.status_code == 403: - break + error_msg = f"Non-200 HTTP code. Got {r.status_code}" + users_fetched = False + break data = r.json() matching_users.extend(list(filter(lambda u: (u["email"] in existing_emails), data["results"]))) page += 1 @@ -74,48 +85,65 @@ class CloudOrganizationConnector(models.Model): fetch_next_page = False except requests.exceptions.RequestException as e: logger.warning(f"Unable to sync users with cloud. Request exception {str(e)}") + error_msg = f"Unable to sync with cloud" + users_fetched = False break - with transaction.atomic(): - cloud_users_identities_to_create = [] - for user in matching_users: - cloud_users_identities_to_create.append( - CloudUserIdentity( - cloud_id=user["id"], - email=user["email"], - phone_number_verified=user["is_phone_number_verified"], - organization=self.organization, + if users_fetched: + with transaction.atomic(): + cloud_users_identities_to_create = [] + for user in matching_users: + cloud_users_identities_to_create.append( + CloudUserIdentity( + cloud_id=user["id"], + email=user["email"], + phone_number_verified=user["is_phone_number_verified"], + organization=self.organization, + ) ) - ) - CloudUserIdentity.objects.filter(organization=self.organization).delete() - CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) + CloudUserIdentity.objects.filter(organization=self.organization).delete() + CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) + + return sync_status, error_msg def sync_user_with_cloud(self, user): + sync_status = False + error_msg = None + api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN if api_token is None: logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. GRAFANA_CLOUD_ONCALL_TOKEN is not set") - return + error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" + else: + url = urljoin(CLOUD_URL, f"api/v1/users/?email={user.email}") + try: + r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5) + if r.status_code != 200: + logger.warning( + f"Unable to sync_user_with_cloud user_id {user.id}. Response status code {r.status_code}" + ) + error_msg = f"Non-200 HTTP code. Got {r.status_code}" + else: + data = r.json() + if len(data["results"]) != 0: + cloud_used_data = data["results"][0] + with transaction.atomic(): + CloudUserIdentity.objects.filter(email=user.emai).delete() + CloudUserIdentity.objects.create( + email=user.email, + organization=user.organization, + phone_number_verified=cloud_used_data["is_phone_number_verified"], + cloud_id=cloud_used_data["id"], + ) + sync_status = True + else: + logger.warning( + f"Unable to sync_user_with_cloud user_id {user.id}. User with {user.email} not found" + ) + error_msg = f"User with email not found {user.email}" + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. Request exception {str(e)}") + error_msg = f"Unable to sync with cloud" - url = urljoin(CLOUD_URL, f"api/v1/users/?email={user.email}") - try: - r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5) - if r.status_code != 200: - logger.warning( - f"Unable to sync_user_with_cloud user_id {user.id}. Response status code {r.status_code}" - ) - return - data = r.json() - if len(data["results"]) != 0: - cloud_used_data = data["results"][0] - CloudUserIdentity.objects.filter(email=user.emai).delete() - CloudUserIdentity.objects.create( - email=user.email, - organization=user.organization, - phone_number_verified=cloud_used_data["is_phone_number_verified"], - cloud_id=cloud_used_data["id"], - ) - else: - logger.warning(f"Unable to sync_user_with_cloud user_id {user.id}. User with {user.email} not found") - except requests.exceptions.RequestException as e: - logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. Request exception {str(e)}") + return sync_status, error_msg diff --git a/engine/apps/oss_installation/serializers/__init__.py b/engine/apps/oss_installation/serializers/__init__.py new file mode 100644 index 00000000..991cf99b --- /dev/null +++ b/engine/apps/oss_installation/serializers/__init__.py @@ -0,0 +1 @@ +from .cloud_user import CloudUserSerializer # noqa: F401 diff --git a/engine/apps/oss_installation/views/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py similarity index 66% rename from engine/apps/oss_installation/views/cloud_user.py rename to engine/apps/oss_installation/serializers/cloud_user.py index 5f5805cb..50b857b4 100644 --- a/engine/apps/oss_installation/views/cloud_user.py +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -1,14 +1,10 @@ from urllib.parse import urljoin -from rest_framework import mixins, serializers, viewsets -from rest_framework.permissions import IsAuthenticated +from rest_framework import serializers import apps.oss_installation.constants as cloud_constants -from apps.api.permissions import ActionPermission, IsOwnerOrAdmin -from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity from apps.user_management.models import User -from common.api_helpers.mixins import PublicPrimaryKeyMixin class CloudUserSerializer(serializers.ModelSerializer): @@ -41,21 +37,3 @@ class CloudUserSerializer(serializers.ModelSerializer): ) cloud_data = {"status": status, "link": link} return cloud_data - - -class CloudUserView( - PublicPrimaryKeyMixin, - mixins.RetrieveModelMixin, - viewsets.GenericViewSet, -): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated, ActionPermission) - - action_object_permissions = { - IsOwnerOrAdmin: ("retrieve",), - } - serializer_class = CloudUserSerializer - - def get_queryset(self): - queryset = User.objects.filter(organization=self.request.user.organization) - return queryset diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 86bb640b..2a1e6d5f 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -2,8 +2,7 @@ from django.urls import path from common.api_helpers.optional_slash_router import optional_slash_path -from .views import CloudHeartbeatStatusView, CloudUsersView -from .views.cloud_user import CloudUserView +from .views import CloudConnectionStatusView, CloudHeartbeatStatusView, CloudUsersView, CloudUserView urlpatterns = [ optional_slash_path("cloud_heartbeat_status", CloudHeartbeatStatusView.as_view(), name="cloud_heartbeat_status"), @@ -17,4 +16,5 @@ urlpatterns = [ ), name="cloud-user-detail", ), + optional_slash_path("cloud_connection_status", CloudConnectionStatusView.as_view(), name="cloud-connection-status"), ] diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 98caf343..66a3be93 100644 --- a/engine/apps/oss_installation/views/__init__.py +++ b/engine/apps/oss_installation/views/__init__.py @@ -1,2 +1,3 @@ from .cloud_heartbeat_status import CloudHeartbeatStatusView # noqa: F401 -from .cloud_users import CloudUsersView # noqa: F401 +from .cloud_status import CloudConnectionStatusView # noqa: F401 +from .cloud_users import CloudUsersView, CloudUserView # noqa: F401 diff --git a/engine/apps/oss_installation/views/cloud_status.py b/engine/apps/oss_installation/views/cloud_status.py new file mode 100644 index 00000000..825fa757 --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_status.py @@ -0,0 +1,19 @@ +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.auth_token.auth import PluginAuthentication +from apps.oss_installation.models import CloudOrganizationConnector + + +class CloudConnectionStatusView(APIView): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated,) + + def get(self, request): + connector = CloudOrganizationConnector.objects.filter(organization=request.user.organization).first() + + response = { + "cloud_connection_status": connector is not None, + } + return Response(response) diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index af3a5cd8..d4bfd345 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -1,13 +1,18 @@ from urllib.parse import urljoin +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from rest_framework.views import APIView import apps.oss_installation.constants as cloud_constants -from apps.api.permissions import IsAdmin +from apps.api.permissions import ActionPermission, IsAdmin, IsOwnerOrAdmin from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.oss_installation.serializers import CloudUserSerializer from apps.user_management.models import User +from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.api_helpers.paginators import HundredPageSizePaginator @@ -16,22 +21,23 @@ class CloudUsersView(HundredPageSizePaginator, APIView): permission_classes = (IsAuthenticated, IsAdmin) def get(self, request): - queryset = User.objects.filter(organization=self.request.user.organization) + organization = request.user.organization - if self.request.user.current_team is not None: - queryset = queryset.filter(teams=self.request.user.current_team).distinct() + queryset = User.objects.filter(organization=organization) + + if request.user.current_team is not None: + queryset = queryset.filter(teams=request.user.current_team).distinct() results = self.paginate_queryset(queryset, request, view=self) emails = list(queryset.values_list("email", flat=True)) - cloud_identities = list( - CloudUserIdentity.objects.filter(organization=self.request.user.organization, email__in=emails) - ) + cloud_identities = list(CloudUserIdentity.objects.filter(organization=organization, email__in=emails)) cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} response = [] - connector = CloudOrganizationConnector.objects.first() + connector = CloudOrganizationConnector.objects.filter(organization=organization) + for user in results: link = None status = cloud_constants.CLOUD_NOT_SYNCED @@ -57,3 +63,41 @@ class CloudUsersView(HundredPageSizePaginator, APIView): ) return self.get_paginated_response(response) + + def post(self, request): + organization = request.user.organization + + connector = CloudOrganizationConnector.objects.filter(organization=organization) + if connector is not None: + sync_status, err = connector.sync_users_with_cloud() + return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err}) + else: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"}) + + +class CloudUserView( + PublicPrimaryKeyMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, ActionPermission) + + action_object_permissions = { + IsOwnerOrAdmin: ("retrieve",), + } + serializer_class = CloudUserSerializer + + def get_queryset(self): + queryset = User.objects.filter(organization=self.request.user.organization) + return queryset + + @action(detail=True, methods=["post"]) + def sync_with_cloud(self, request, pk): + user = self.get_object() + connector = CloudOrganizationConnector.objects.filter(organization=request["request"].auth.organization).first() + if connector is not None: + sync_status, err = connector.sync_user_with_cloud(user) + return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err}) + else: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"}) From 848a1d066ce71bd70932fec2cb922f6912cac3c6 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Mon, 6 Jun 2022 13:23:34 +0200 Subject: [PATCH 009/132] Cloud tab WIP --- grafana-plugin/src/GrafanaPluginRootPage.tsx | 2 + .../containers/UserSettings/UserSettings.tsx | 5 +- .../containers/UserSettings/parts/index.tsx | 13 +- .../CloudPhoneSettings.module.css | 3 + .../CloudPhoneSettings/CloudPhoneSettings.tsx | 83 ++++++ grafana-plugin/src/models/cloud/cloud.ts | 59 ++++ .../src/models/cloud/cloud.types.ts | 6 + grafana-plugin/src/models/user/user.types.ts | 2 + .../src/pages/cloud/CloudPage.module.css | 24 ++ grafana-plugin/src/pages/cloud/CloudPage.tsx | 260 ++++++++++++++++++ grafana-plugin/src/pages/index.ts | 7 + grafana-plugin/src/state/rootBaseStore.ts | 2 + grafana-plugin/src/vars.css | 4 + 13 files changed, 464 insertions(+), 6 deletions(-) create mode 100644 grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.module.css create mode 100644 grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx create mode 100644 grafana-plugin/src/models/cloud/cloud.ts create mode 100644 grafana-plugin/src/models/cloud/cloud.types.ts create mode 100644 grafana-plugin/src/pages/cloud/CloudPage.module.css create mode 100644 grafana-plugin/src/pages/cloud/CloudPage.tsx diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index a3276a5f..aacc6f44 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -100,9 +100,11 @@ export const Root = observer((props: AppRootProps) => { const style = document.createElement('style'); document.head.appendChild(style); const index = style.sheet.insertRule('.page-body {max-width: unset !important}'); + const index2 = style.sheet.insertRule('.page-container {max-width: unset !important}'); return () => { style.sheet.removeRule(index); + style.sheet.removeRule(index2); }; }, []); diff --git a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx index ca00871b..3ce67136 100644 --- a/grafana-plugin/src/containers/UserSettings/UserSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/UserSettings.tsx @@ -5,12 +5,12 @@ import cn from 'classnames/bind'; import { observer } from 'mobx-react'; import { useMediaQuery } from 'react-responsive'; -import { Tabs, TabsContent } from 'containers/UserSettings/parts'; import { User as UserType } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { UserSettingsTab } from './UserSettings.types'; +import { Tabs, TabsContent } from './parts'; import styles from './UserSettings.module.css'; @@ -58,7 +58,8 @@ const UserSettings = observer((props: UserFormProps) => { setActiveTab(tab); }, []); - const isModalWide = activeTab === UserSettingsTab.UserInfo && isDesktopOrLaptop; + const isModalWide = + (activeTab === UserSettingsTab.UserInfo && isDesktopOrLaptop) || activeTab === UserSettingsTab.PhoneVerification; const [showNotificationSettingsTab, showSlackConnectionTab, showTelegramConnectionTab, showMobileAppVerificationTab] = [ diff --git a/grafana-plugin/src/containers/UserSettings/parts/index.tsx b/grafana-plugin/src/containers/UserSettings/parts/index.tsx index 19e598ed..7f966bfc 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { Tab, TabContent, TabsBar } from '@grafana/ui'; import cn from 'classnames/bind'; @@ -7,6 +7,7 @@ import Block from 'components/GBlock/Block'; import MobileAppVerification from 'containers/MobileAppVerification/MobileAppVerification'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab'; +import CloudPhoneSettings from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings'; import { NotificationSettingsTab } from 'containers/UserSettings/parts/tabs/NotificationSettingsTab'; import PhoneVerification from 'containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification'; import TelegramInfo from 'containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo'; @@ -105,6 +106,7 @@ export const TabsContent = (props: TabsContentProps) => { const store = useStore(); const { userStore } = store; + const [isPhoneEnabled, setIsPhoneEnabled] = useState(false); const storeUser = userStore.items[id]; @@ -124,9 +126,12 @@ export const TabsContent = (props: TabsContentProps) => { ))} {activeTab === UserSettingsTab.NotificationSettings && } - {activeTab === UserSettingsTab.PhoneVerification && ( - - )} + {activeTab === UserSettingsTab.PhoneVerification && + (isPhoneEnabled ? ( + + ) : ( + + ))} {activeTab === UserSettingsTab.MobileAppVerification && ( )} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.module.css b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.module.css new file mode 100644 index 00000000..ab86c434 --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.module.css @@ -0,0 +1,3 @@ +.test { + color: grey; +} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx new file mode 100644 index 00000000..08f94cd6 --- /dev/null +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -0,0 +1,83 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; +import { Field, Input, Button, Modal, HorizontalGroup, Alert, Icon, VerticalGroup, Table } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Block from 'components/GBlock/Block'; +import GTable from 'components/GTable/GTable'; +import PluginLink from 'components/PluginLink/PluginLink'; +import Text from 'components/Text/Text'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import { User as UserType } from 'models/user/user.types'; +import { WithStoreProps } from 'state/types'; +import { withMobXProviderContext } from 'state/withStore'; + +import styles from './CloudPhoneSettings.module.css'; + +const cx = cn.bind(styles); + +interface CloudPhoneSettingsProps extends WithStoreProps {} + +const CloudPhoneSettings = (props: CloudPhoneSettingsProps) => { + const [isAccountMatched, setIsAccountMatched] = useState(true); + const [isPhoneVerified, setIsPhoneVerified] = useState(true); + + const signUpGrafanaCloud = () => { + console.log('Sign UP'); + }; + const handleLinkClick = (link: string) => { + getLocationSrv().update({ partial: false, path: link }); + }; + + return ( + + + OnCall use Grafana Cloud for SMS and phone call notifications + + + {isAccountMatched ? ( + isPhoneVerified ? ( + + + Your account successfully matched with the Grafana Cloud account. Please verify your phone number.{' '} + + + + ) : ( + + + Your account successfully matched with the Grafana Cloud account. Your phone number is verified. + + + + ) + ) : ( + + + {'We can’t find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). '} + + + + )} + + ); +}; + +export default withMobXProviderContext(CloudPhoneSettings); diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts new file mode 100644 index 00000000..d5c40049 --- /dev/null +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -0,0 +1,59 @@ +import { get } from 'lodash-es'; +import { action, computed, observable } from 'mobx'; + +import BaseStore from 'models/base_store'; +import { NotificationPolicyType } from 'models/notification_policy'; +import { makeRequest } from 'network'; +import { Mixpanel } from 'services/mixpanel'; +import { RootStore } from 'state'; +import { move } from 'state/helpers'; + +import { Cloud } from './cloud.types'; + +export class CloudStore extends BaseStore { + @observable.shallow + searchResult: { count?: number; results?: Array } = {}; + + @observable.shallow + items: { [id: string]: Cloud } = {}; + + constructor(rootStore: RootStore) { + super(rootStore); + + this.path = '/cloud_users/'; + } + + @action + async updateItems(f: any = { searchTerm: '' }, page = 1) { + const filters = typeof f === 'string' ? { searchTerm: f } : f; // for GSelect compatibility + const { searchTerm: search } = filters; + const { count, results } = await makeRequest(this.path, { + params: { search, page }, + }); + + this.items = { + ...this.items, + ...results.reduce( + (acc: { [key: number]: Cloud }, item: Cloud) => ({ + ...acc, + [item.id]: item, + }), + {} + ), + }; + + this.searchResult = { + count, + results: results.map((item: Cloud) => item.id), + }; + } + + getSearchResult() { + return { + count: this.searchResult.count, + results: + this.searchResult.results && + this.searchResult.results.map((cloud_user_id: Cloud['id']) => this.items?.[cloud_user_id]), + }; + } +} diff --git a/grafana-plugin/src/models/cloud/cloud.types.ts b/grafana-plugin/src/models/cloud/cloud.types.ts new file mode 100644 index 00000000..2aa411a1 --- /dev/null +++ b/grafana-plugin/src/models/cloud/cloud.types.ts @@ -0,0 +1,6 @@ +export interface Cloud { + id: string; + username: string; + cloud_sync_status?: number; + link?: string; +} diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index cb4e03bf..4dd3f00a 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -50,4 +50,6 @@ export interface User { permissions: UserAction[]; trigger_video_call?: boolean; export_url?: string; + status?: number; + link?: string; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.module.css b/grafana-plugin/src/pages/cloud/CloudPage.module.css new file mode 100644 index 00000000..387b4c57 --- /dev/null +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -0,0 +1,24 @@ +.info-block { + width: 70%; +} + +.warning-message { + color: var(--warning-text-color); +} + +.success-message { + color: var(--success-text-color); +} + +.error-message { + color: var(--error-text-color); +} + +.user-table { + margin-top: 24px; + width: 100%; +} + +.cloud-oncall-name { + color: #f55f3e; +} diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx new file mode 100644 index 00000000..e92d849f --- /dev/null +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -0,0 +1,260 @@ +import React, { useCallback, useEffect, useState } from 'react'; + +import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; +import { Field, Input, Button, Modal, HorizontalGroup, Alert, Icon, VerticalGroup, Table } from '@grafana/ui'; +import cn from 'classnames/bind'; + +import Block from 'components/GBlock/Block'; +import GTable from 'components/GTable/GTable'; +import PluginLink from 'components/PluginLink/PluginLink'; +import Text from 'components/Text/Text'; +import WithConfirm from 'components/WithConfirm/WithConfirm'; +import { HeartGreenIcon, HeartRedIcon } from 'icons'; +import { Cloud } from 'models/cloud/cloud.types'; +import { WithStoreProps } from 'state/types'; +import { useStore } from 'state/useStore'; +import { withMobXProviderContext } from 'state/withStore'; + +import styles from './CloudPage.module.css'; + +const cx = cn.bind(styles); + +interface CloudPageProps extends WithStoreProps {} + +const CloudPage = (props: CloudPageProps) => { + const store = useStore(); + const [cloudApiKey, setCloudApiKey] = useState(''); + const [cloudIsConnected, setCloudIsConnected] = useState(true); + const [showConfirmationModal, setShowConfirmationModal] = useState(false); + + useEffect(() => { + store.cloudStore.updateItems(); + }, []); + + const usersCount = 3; + const data = [ + { id: 'yshanyrova', username: 'y.shanyrova@grafana.com', cloud_sync_status: 2, link: '/test/abc' }, + { id: 'amixradmin', username: 'amixr-admin@grafana.com', cloud_sync_status: 1, link: '/test/qwerty' }, + { id: 'amixr', username: 'amixr@grafana.com', cloud_sync_status: undefined, link: undefined }, + ]; + + // const data = store.cloudStore.getSearchResult(); + const handleChangeCloudApiKey = useCallback((e) => { + setCloudApiKey(e.target.value); + }, []); + + const saveKeyAndConnect = () => { + setShowConfirmationModal(true); + }; + + const disconnectCloudOncall = () => { + console.log('disconnected'); + setCloudIsConnected(false); + }; + + const connectToCloud = () => { + console.log('CONNECT TO CLOUD'); + setCloudIsConnected(true); + setShowConfirmationModal(false); + }; + + const syncUsers = () => { + console.log('Sync Users'); + }; + + const handleLinkClick = (link: string) => { + getLocationSrv().update({ partial: false, path: link }); + }; + + const renderButtons = (user: Cloud) => { + switch (user.cloud_sync_status) { + case 0: + return null; + case 1: + return ( + + ); + case 2: + return ( + + ); + default: + return null; + } + }; + + const renderStatus = (user: Cloud) => { + switch (user.cloud_sync_status) { + case 0: + return User not found in the Grafana Cloud; + case 1: + return Phone number verified; + + case 2: + return Phone number is not verified in Grafana Cloud; + default: + return User not found in Grafana Cloud; + } + }; + + const renderStatusIcon = (user: Cloud) => { + switch (user.cloud_sync_status) { + case 0: + return ; + case 1: + return ; + + case 2: + return ; + default: + return ; + } + }; + + const renderEmail = (user: Cloud) => { + return {user.username}; + }; + + const columns = [ + { + width: '5%', + render: renderStatusIcon, + key: 'statusIcon', + }, + { + width: '30%', + render: renderEmail, + key: 'email', + }, + { + width: '35%', + render: renderStatus, + key: 'status', + }, + { + width: '30%', + render: renderButtons, + key: 'buttons', + align: 'actions', + }, + ]; + + return ( +
+ + + Connect Open Source OnCall and Cloud OnCall + + + {cloudIsConnected ? ( + + + Cloud OnCall API key + + Cloud OnCall is sucessfully connected. + + + + + + ) : ( + + + Cloud OnCall API key + + + + + + + )} + + + {showConfirmationModal && ( + setShowConfirmationModal(false)} + > + + + + + + )} + + + + + Monitor cloud instance with heartbeat + + + Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no + heartbeat will be received in 10 minutes, cloud instance will issue an alert. + + {cloudIsConnected && ( + + )} + + + + + + + SMS and phone call notifications + + {cloudIsConnected ? ( +
+ + { + 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings!' + } + + + ( + + {`${usersCount} users matched between OSS and Cloud OnCall`} + + + )} + rowKey="id" + // @ts-ignore + columns={columns} + data={data} + /> +
+ ) : ( + Users matched between OSS and Cloud OnCall currently unavialable. + )} +
+
+
+
+ ); +}; + +export default withMobXProviderContext(CloudPage); diff --git a/grafana-plugin/src/pages/index.ts b/grafana-plugin/src/pages/index.ts index cd2c68a3..e7891eca 100644 --- a/grafana-plugin/src/pages/index.ts +++ b/grafana-plugin/src/pages/index.ts @@ -3,6 +3,7 @@ import React from 'react'; import { AppRootProps } from '@grafana/data'; import ChatOpsPage from 'pages/chat-ops/ChatOps'; +import CloudPage from 'pages/cloud/CloudPage'; import EscalationsChainsPage from 'pages/escalation-chains/EscalationChains'; import IncidentPage2 from 'pages/incident/Incident'; import IncidentsPage2 from 'pages/incidents/Incidents'; @@ -116,6 +117,12 @@ export const pages: PageDefinition[] = [ text: 'Migrate From Amixr.IO', hideFromTabs: true, }, + { + component: CloudPage, + icon: 'cloud', + id: 'cloud', + text: 'Cloud', + }, { component: Test, icon: 'cog', diff --git a/grafana-plugin/src/state/rootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore.ts index 5900ab1f..331f6ca1 100644 --- a/grafana-plugin/src/state/rootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore.ts @@ -9,6 +9,7 @@ import { AlertReceiveChannel } from 'models/alert_receive_channel/alert_receive_ import { AlertReceiveChannelFiltersStore } from 'models/alert_receive_channel_filters/alert_receive_channel_filters'; import { AlertGroupStore } from 'models/alertgroup/alertgroup'; import { ApiTokenStore } from 'models/api_token/api_token'; +import { CloudStore } from 'models/cloud/cloud'; import { EscalationChainStore } from 'models/escalation_chain/escalation_chain'; import { EscalationPolicyStore } from 'models/escalation_policy/escalation_policy'; import { GlobalSettingStore } from 'models/global_setting/global_setting'; @@ -81,6 +82,7 @@ export class RootBaseStore { // -------------------------- userStore: UserStore = new UserStore(this); + cloudStore: CloudStore = new CloudStore(this); grafanaTeamStore: GrafanaTeamStore = new GrafanaTeamStore(this); alertReceiveChannelStore: AlertReceiveChannelStore = new AlertReceiveChannelStore(this); outgoingWebhookStore: OutgoingWebhookStore = new OutgoingWebhookStore(this); diff --git a/grafana-plugin/src/vars.css b/grafana-plugin/src/vars.css index a0af933b..0216e04c 100644 --- a/grafana-plugin/src/vars.css +++ b/grafana-plugin/src/vars.css @@ -22,6 +22,8 @@ --secondary-text-color: rgba(36, 41, 46, 0.75); --disabled-text-color: rgba(36, 41, 46, 0.5); --warning-text-color: #8a6c00; + --success-text-color: rgb(10, 118, 78); + --error-text-color: rgb(207, 14, 91); --primary-text-link: #1f62e0; --timeline-icon-background: rgba(70, 76, 84, 0); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 0); @@ -38,6 +40,8 @@ --secondary-text-color: rgba(204, 204, 220, 0.65); --disabled-text-color: rgba(204, 204, 220, 0.4); --warning-text-color: #f8d06b; + --success-text-color: rgb(108, 207, 142); + --error-text-color: rgb(255, 82, 134); --primary-text-link: #6e9fff; --timeline-icon-background: rgba(70, 76, 84, 1); --timeline-icon-background-resolution-note: rgba(50, 116, 217, 1); From 829ed8230b8bade00f4d1648ac4276e5e24a4b0f Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 6 Jun 2022 16:02:09 +0400 Subject: [PATCH 010/132] Make CloudConnection instance wide --- engine/apps/api/views/live_setting.py | 10 +++++ engine/apps/base/models/live_setting.py | 1 + engine/apps/base/utils.py | 20 +++------ engine/apps/oss_installation/constants.py | 2 - .../apps/oss_installation/models/__init__.py | 4 +- ...zation_connector.py => cloud_connector.py} | 41 +++++++++++-------- .../{heartbeat.py => cloud_heartbeat.py} | 0 .../models/cloud_user_identity.py | 12 +++--- .../serializers/cloud_user.py | 6 +-- engine/apps/oss_installation/urls.py | 5 +-- engine/apps/oss_installation/utils.py | 36 ++++++++++++++-- .../apps/oss_installation/views/__init__.py | 4 +- .../views/cloud_connection.py | 35 ++++++++++++++++ .../views/cloud_heartbeat_status.py | 15 ------- .../oss_installation/views/cloud_status.py | 19 --------- .../oss_installation/views/cloud_users.py | 12 +++--- engine/engine/urls.py | 2 +- engine/settings/base.py | 2 +- 18 files changed, 128 insertions(+), 98 deletions(-) rename engine/apps/oss_installation/models/{cloud_organization_connector.py => cloud_connector.py} (83%) rename engine/apps/oss_installation/models/{heartbeat.py => cloud_heartbeat.py} (100%) create mode 100644 engine/apps/oss_installation/views/cloud_connection.py delete mode 100644 engine/apps/oss_installation/views/cloud_heartbeat_status.py delete mode 100644 engine/apps/oss_installation/views/cloud_status.py diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index 2ed6d723..4c9b7beb 100644 --- a/engine/apps/api/views/live_setting.py +++ b/engine/apps/api/views/live_setting.py @@ -12,6 +12,7 @@ from apps.api.serializers.live_setting import LiveSettingSerializer from apps.auth_token.auth import PluginAuthentication from apps.base.models import LiveSetting from apps.base.utils import live_settings +from apps.oss_installation.models import CloudConnector from apps.slack.tasks import unpopulate_slack_user_identities from apps.telegram.client import TelegramClient from apps.telegram.tasks import register_telegram_webhook @@ -66,6 +67,15 @@ class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet): if sti is not None: unpopulate_slack_user_identities.apply_async((sti.pk, True), countdown=0) + if instance.name == "GRAFANA_CLOUD_ONCALL_TOKEN": + try: + old_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + except ImproperlyConfigured: + old_token = None + + if old_token != new_value: + CloudConnector.remove_sync() + def _reset_telegram_integration(self, new_token): # tell Telegram to cancel sending events from old bot with suppress(ImproperlyConfigured, error.InvalidToken, error.Unauthorized): diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index 1c0b806a..ca3331de 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -173,4 +173,5 @@ class LiveSetting(models.Model): ) self.error = LiveSettingValidator(live_setting=self).get_error() + super().save(*args, **kwargs) diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index 0f9f04b7..a3b5a657 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -1,16 +1,15 @@ import json import re -from urllib.parse import urljoin -import requests.exceptions from django.apps import apps -from django.conf import settings from python_http_client import UnauthorizedError from sendgrid import SendGridAPIClient from telegram import Bot from twilio.base.exceptions import TwilioException from twilio.rest import Client +from apps.oss_installation.models import CloudConnector + class LiveSettingProxy: def __dir__(self): @@ -98,18 +97,9 @@ class LiveSettingValidator: return f"Telegram error: {str(e)}" @classmethod - def _check_grafana_cloud_oncall_token(cls, grafan_oncall_token): - try: - info_url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") - r = requests.get(info_url, headers={"AUTHORIZATION": grafan_oncall_token}, timeout=5) - if r.status_code == 200: - return - elif r.status_code == 403: - return f"Invalid token" - else: - return f"Non-200 HTTP code. Got {r.status_code}" - except requests.exceptions.RequestException as e: - return f"Error {str(e)}" + def _check_grafana_cloud_oncall_token(cls, grafana_oncall_token): + _, err = CloudConnector.sync_with_cloud(grafana_oncall_token) + return err @staticmethod def _is_email_valid(email): diff --git a/engine/apps/oss_installation/constants.py b/engine/apps/oss_installation/constants.py index db777bb3..11f3dc48 100644 --- a/engine/apps/oss_installation/constants.py +++ b/engine/apps/oss_installation/constants.py @@ -1,5 +1,3 @@ -CLOUD_URL = "https://a-prod-us-central-0.grafana.net/" - CLOUD_NOT_SYNCED = 0 CLOUD_SYNCED_USER_NOT_FOUND = 1 CLOUD_SYNCED_PHONE_NOT_VERIFIED = 2 diff --git a/engine/apps/oss_installation/models/__init__.py b/engine/apps/oss_installation/models/__init__.py index 2ee74128..beab1774 100644 --- a/engine/apps/oss_installation/models/__init__.py +++ b/engine/apps/oss_installation/models/__init__.py @@ -1,4 +1,4 @@ -from .cloud_organization_connector import CloudOrganizationConnector # noqa: F401 +from .cloud_connector import CloudConnector # noqa: F401 +from .cloud_heartbeat import CloudHeartbeat # noqa: F401 from .cloud_user_identity import CloudUserIdentity # noqa: F401 -from .heartbeat import CloudHeartbeat # noqa: F401 from .oss_installation import OssInstallation # noqa: F401 diff --git a/engine/apps/oss_installation/models/cloud_organization_connector.py b/engine/apps/oss_installation/models/cloud_connector.py similarity index 83% rename from engine/apps/oss_installation/models/cloud_organization_connector.py rename to engine/apps/oss_installation/models/cloud_connector.py index 126d9b65..1434b1ba 100644 --- a/engine/apps/oss_installation/models/cloud_organization_connector.py +++ b/engine/apps/oss_installation/models/cloud_connector.py @@ -5,50 +5,53 @@ import requests from django.db import models, transaction from apps.base.utils import live_settings -from apps.oss_installation.constants import CLOUD_URL +from apps.oss_installation.models import CloudHeartbeat from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity from apps.user_management.models import User +from settings.base import GRAFANA_CLOUD_ONCALL_API_URL logger = logging.getLogger(__name__) -class CloudOrganizationConnector(models.Model): +class CloudConnector(models.Model): """ CloudOrganizationConnector model represents connection between oss organization and cloud organization. """ cloud_url = models.URLField() - organization = models.OneToOneField( - "user_management.organization", related_name="cloud_connector", on_delete=models.CASCADE - ) + # organization = models.OneToOneField( + # "user_management.organization", related_name="cloud_connector", on_delete=models.CASCADE + # ) @classmethod - def sync_with_cloud(cls, organization): + def sync_with_cloud(cls, token=None): """ sync_with_cloud sync organization with cloud organization defined by provided GRAFANA_CLOUD_ONCALL_TOKEN. """ sync_status = False error_msg = None - api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + api_token = token or live_settings.GRAFANA_CLOUD_ONCALL_TOKEN if api_token is None: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" else: - info_url = urljoin(CLOUD_URL, "api/v1/info/") + info_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") try: r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5) if r.status_code == 200: - cls.objects.update_or_create(organization=organization, defaults={"cloud_url": r.json()["url"]}) - sync_status = True + connector = cls.objects.get_or_create() + connector.cloud_url = r.json()["url"] + connector.save() elif r.status_code == 403: logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid") - error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is invalid" + error_msg = "Invalid token" else: error_msg = f"Non-200 HTTP code. Got {r.status_code}" except requests.exceptions.RequestException as e: logger.warning(f"Unable to sync with cloud. Request exception {str(e)}") error_msg = f"Unable to sync with cloud" + return sync_status, error_msg def sync_users_with_cloud(self) -> tuple[bool, str]: @@ -60,9 +63,9 @@ class CloudOrganizationConnector(models.Model): logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" - existing_emails = list(User.objects.filter(organization=self.organization).values_list("email", flat=True)) + existing_emails = list(User.objects.values_list("email", flat=True)) matching_users = [] - users_url = urljoin(CLOUD_URL, "api/v1/users") + users_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/users") fetch_next_page = True users_fetched = True @@ -98,11 +101,10 @@ class CloudOrganizationConnector(models.Model): cloud_id=user["id"], email=user["email"], phone_number_verified=user["is_phone_number_verified"], - organization=self.organization, ) ) - CloudUserIdentity.objects.filter(organization=self.organization).delete() + CloudUserIdentity.objects.delete() CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) return sync_status, error_msg @@ -116,7 +118,7 @@ class CloudOrganizationConnector(models.Model): logger.warning(f"Unable to sync_user_with cloud user_id {user.id}. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" else: - url = urljoin(CLOUD_URL, f"api/v1/users/?email={user.email}") + url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, f"api/v1/users/?email={user.email}") try: r = requests.get(url, headers={"AUTHORIZATION": api_token}, timeout=5) if r.status_code != 200: @@ -132,7 +134,6 @@ class CloudOrganizationConnector(models.Model): CloudUserIdentity.objects.filter(email=user.emai).delete() CloudUserIdentity.objects.create( email=user.email, - organization=user.organization, phone_number_verified=cloud_used_data["is_phone_number_verified"], cloud_id=cloud_used_data["id"], ) @@ -147,3 +148,9 @@ class CloudOrganizationConnector(models.Model): error_msg = f"Unable to sync with cloud" return sync_status, error_msg + + @classmethod + def remove_sync(cls): + cls.objects.delete() + CloudUserIdentity.objects.delete() + CloudHeartbeat.objects.delete() diff --git a/engine/apps/oss_installation/models/heartbeat.py b/engine/apps/oss_installation/models/cloud_heartbeat.py similarity index 100% rename from engine/apps/oss_installation/models/heartbeat.py rename to engine/apps/oss_installation/models/cloud_heartbeat.py diff --git a/engine/apps/oss_installation/models/cloud_user_identity.py b/engine/apps/oss_installation/models/cloud_user_identity.py index fca0eebe..1918ddcb 100644 --- a/engine/apps/oss_installation/models/cloud_user_identity.py +++ b/engine/apps/oss_installation/models/cloud_user_identity.py @@ -5,9 +5,9 @@ class CloudUserIdentity(models.Model): phone_number_verified = models.BooleanField(default=False) cloud_id = models.CharField(max_length=20) email = models.EmailField() - organization = models.ForeignKey( - "user_management.Organization", on_delete=models.CASCADE, related_name="cloud_users" - ) - - class Meta: - unique_together = ("email", "organization") + # organization = models.ForeignKey( + # "user_management.Organization", on_delete=models.CASCADE, related_name="cloud_users" + # ) + # + # class Meta: + # unique_together = ("email", "organization") diff --git a/engine/apps/oss_installation/serializers/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py index 50b857b4..d8e35791 100644 --- a/engine/apps/oss_installation/serializers/cloud_user.py +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -3,7 +3,7 @@ from urllib.parse import urljoin from rest_framework import serializers import apps.oss_installation.constants as cloud_constants -from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.oss_installation.models import CloudConnector, CloudUserIdentity from apps.user_management.models import User @@ -17,9 +17,7 @@ class CloudUserSerializer(serializers.ModelSerializer): def get_cloud_data(self, obj): link = None status = cloud_constants.CLOUD_NOT_SYNCED - connector = CloudOrganizationConnector.objects.filter( - organization=self.context["request"].auth.organization - ).first() + connector = CloudConnector.objects.filter().first() if connector is not None: cloud_user_identity = CloudUserIdentity.objects.filter(email=obj.email).first() if cloud_user_identity is None: diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 2a1e6d5f..25708249 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -2,10 +2,9 @@ from django.urls import path from common.api_helpers.optional_slash_router import optional_slash_path -from .views import CloudConnectionStatusView, CloudHeartbeatStatusView, CloudUsersView, CloudUserView +from .views import CloudConnectionView, CloudUsersView, CloudUserView urlpatterns = [ - optional_slash_path("cloud_heartbeat_status", CloudHeartbeatStatusView.as_view(), name="cloud_heartbeat_status"), optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud-users-list"), path( "cloud_users/", @@ -16,5 +15,5 @@ urlpatterns = [ ), name="cloud-user-detail", ), - optional_slash_path("cloud_connection_status", CloudConnectionStatusView.as_view(), name="cloud-connection-status"), + optional_slash_path("cloud_connection", CloudConnectionView.as_view(), name="cloud-connection-status"), ] diff --git a/engine/apps/oss_installation/utils.py b/engine/apps/oss_installation/utils.py index fcfb537c..c0ca366c 100644 --- a/engine/apps/oss_installation/utils.py +++ b/engine/apps/oss_installation/utils.py @@ -1,19 +1,27 @@ +import logging from contextlib import suppress +from urllib.parse import urljoin +import requests +from django.apps import apps from django.utils import timezone -from apps.alerts.models import AlertGroupLogRecord, EscalationPolicy -from apps.base.models import UserNotificationPolicyLogRecord from apps.public_api.constants import DEMO_USER_ID from apps.schedules.ical_utils import list_users_to_notify_from_ical_for_period -from apps.schedules.models import OnCallSchedule -from apps.user_management.models import User +from settings.base import GRAFANA_CLOUD_ONCALL_API_URL + +logger = logging.getLogger(__name__) def active_oss_users_count(): """ active_oss_users_count returns count of active users of oss installation. """ + OnCallSchedule = apps.get_model("schedules", "OnCallSchedule") + AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord") + EscalationPolicy = apps.get_model("alerts", "EscalationPolicy") + UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") + User = apps.get_model("user_management", "User") # Take logs for previous 24 hours start = timezone.now() - timezone.timedelta(hours=24) @@ -68,3 +76,23 @@ def active_oss_users_count(): with suppress(KeyError): unique_active_users.remove(demo_user.pk) return len(unique_active_users) + + +def get_cloud_instance_info(api_token): + success = False + error_msg = None + r = None + info_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") + try: + r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5) + if r.status_code == 200: + success = True + elif r.status_code == 403: + logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid") + error_msg = "Invalid token" + else: + error_msg = f"Non-200 HTTP code. Got {r.status_code}" + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to sync with cloud. Request exception {str(e)}") + error_msg = f"Unable to sync with cloud" + return success, error_msg, r diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 66a3be93..9cbe8980 100644 --- a/engine/apps/oss_installation/views/__init__.py +++ b/engine/apps/oss_installation/views/__init__.py @@ -1,3 +1,3 @@ -from .cloud_heartbeat_status import CloudHeartbeatStatusView # noqa: F401 -from .cloud_status import CloudConnectionStatusView # noqa: F401 +from .cloud_connection import CloudConnectionView # noqa: F401 +from .cloud_heartbeat import CloudHeartbeatStatusView # noqa: F401 from .cloud_users import CloudUsersView, CloudUserView # noqa: F401 diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py new file mode 100644 index 00000000..cf8e4713 --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -0,0 +1,35 @@ +from urllib.parse import urljoin + +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.api.permissions import IsAdmin +from apps.auth_token.auth import PluginAuthentication +from apps.base.utils import live_settings +from apps.oss_installation.models import CloudConnector, CloudHeartbeat + + +class CloudConnectionView(APIView): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, IsAdmin) + + def get(self, request): + connector = CloudConnector.objects.first() + heartbeat = CloudHeartbeat.objects.first() + response = { + "cloud_connection_status": connector is not None, + "token": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN, + "cloud_notifications_enabled": live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, + "cloud_heartbeat_enabled": live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED, + "cloud_heartbeat_link": self._get_heartbeat_link(connector, heartbeat), + "cloud_heartbeat_status": heartbeat is not None and heartbeat.success, + } + return Response(response) + + def _get_heartbeat_link(self, connector, heartbeat): + if connector is None: + return None + if heartbeat is None: + return None + return urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=integrations1&id={heartbeat.integration_id}") diff --git a/engine/apps/oss_installation/views/cloud_heartbeat_status.py b/engine/apps/oss_installation/views/cloud_heartbeat_status.py deleted file mode 100644 index be553641..00000000 --- a/engine/apps/oss_installation/views/cloud_heartbeat_status.py +++ /dev/null @@ -1,15 +0,0 @@ -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.auth_token.auth import PluginAuthentication -from apps.oss_installation.models import CloudHeartbeat - - -class CloudHeartbeatStatusView(APIView): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated,) - - def get(self, request): - response = {"status": CloudHeartbeat.status()} - return Response(response) diff --git a/engine/apps/oss_installation/views/cloud_status.py b/engine/apps/oss_installation/views/cloud_status.py deleted file mode 100644 index 825fa757..00000000 --- a/engine/apps/oss_installation/views/cloud_status.py +++ /dev/null @@ -1,19 +0,0 @@ -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.auth_token.auth import PluginAuthentication -from apps.oss_installation.models import CloudOrganizationConnector - - -class CloudConnectionStatusView(APIView): - authentication_classes = (PluginAuthentication,) - permission_classes = (IsAuthenticated,) - - def get(self, request): - connector = CloudOrganizationConnector.objects.filter(organization=request.user.organization).first() - - response = { - "cloud_connection_status": connector is not None, - } - return Response(response) diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index d4bfd345..ab28c677 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -9,7 +9,7 @@ from rest_framework.views import APIView import apps.oss_installation.constants as cloud_constants from apps.api.permissions import ActionPermission, IsAdmin, IsOwnerOrAdmin from apps.auth_token.auth import PluginAuthentication -from apps.oss_installation.models import CloudOrganizationConnector, CloudUserIdentity +from apps.oss_installation.models import CloudConnector, CloudUserIdentity from apps.oss_installation.serializers import CloudUserSerializer from apps.user_management.models import User from common.api_helpers.mixins import PublicPrimaryKeyMixin @@ -31,12 +31,12 @@ class CloudUsersView(HundredPageSizePaginator, APIView): results = self.paginate_queryset(queryset, request, view=self) emails = list(queryset.values_list("email", flat=True)) - cloud_identities = list(CloudUserIdentity.objects.filter(organization=organization, email__in=emails)) + cloud_identities = list(CloudUserIdentity.objects.filter(email__in=emails)) cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} response = [] - connector = CloudOrganizationConnector.objects.filter(organization=organization) + connector = CloudConnector.objects.first() for user in results: link = None @@ -65,9 +65,7 @@ class CloudUsersView(HundredPageSizePaginator, APIView): return self.get_paginated_response(response) def post(self, request): - organization = request.user.organization - - connector = CloudOrganizationConnector.objects.filter(organization=organization) + connector = CloudConnector.objects.first() if connector is not None: sync_status, err = connector.sync_users_with_cloud() return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err}) @@ -95,7 +93,7 @@ class CloudUserView( @action(detail=True, methods=["post"]) def sync_with_cloud(self, request, pk): user = self.get_object() - connector = CloudOrganizationConnector.objects.filter(organization=request["request"].auth.organization).first() + connector = CloudConnector.objects.first() if connector is not None: sync_status, err = connector.sync_user_with_cloud(user) return Response(status=status.HTTP_200_OK, data={"status": sync_status, "error": err}) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 9e55241a..702e2907 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -54,7 +54,7 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED: path("slack/", include("apps.slack.urls")), ] -if settings.OSS_INSTALLATION_FEATURES_ENABLED: +if settings.OSS_INSTALLATION_FEATURES_ENABLED or True: urlpatterns += [ path("api/internal/v1/", include("apps.oss_installation.urls")), ] diff --git a/engine/settings/base.py b/engine/settings/base.py index 9bb227f9..281a8a86 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -409,7 +409,7 @@ SELF_HOSTED_SETTINGS = { "ORG_TITLE": "Self-Hosted Organization", } -GRAFANA_CLOUD_ONCALL_API_URL = os.environ.get("GRAFANA_CLOUD_ONCALL_API_URL", "https://a-prod-us-central-0.grafana.net") +GRAFANA_CLOUD_ONCALL_API_URL = "https://a-02-dev-us-central-0.grafana.net/" GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None) GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) From 874824017289e8c38f3cedc52534e21d9622f224 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 6 Jun 2022 16:20:05 +0400 Subject: [PATCH 011/132] Remove token from cloud_connection view --- engine/apps/oss_installation/views/cloud_connection.py | 1 - 1 file changed, 1 deletion(-) diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py index cf8e4713..5fe2ba47 100644 --- a/engine/apps/oss_installation/views/cloud_connection.py +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -19,7 +19,6 @@ class CloudConnectionView(APIView): heartbeat = CloudHeartbeat.objects.first() response = { "cloud_connection_status": connector is not None, - "token": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN, "cloud_notifications_enabled": live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, "cloud_heartbeat_enabled": live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED, "cloud_heartbeat_link": self._get_heartbeat_link(connector, heartbeat), From ae0845d6a7adb30305ff3ab746e033ab2564dfd2 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 6 Jun 2022 16:36:49 +0400 Subject: [PATCH 012/132] Add migration --- engine/apps/base/utils.py | 4 ++-- .../migrations/0001_squashed_initial.py | 16 ++++++++++++++++ .../oss_installation/models/cloud_connector.py | 15 ++++++++------- engine/apps/oss_installation/views/__init__.py | 1 - 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/engine/apps/base/utils.py b/engine/apps/base/utils.py index a3b5a657..8339e295 100644 --- a/engine/apps/base/utils.py +++ b/engine/apps/base/utils.py @@ -8,8 +8,6 @@ from telegram import Bot from twilio.base.exceptions import TwilioException from twilio.rest import Client -from apps.oss_installation.models import CloudConnector - class LiveSettingProxy: def __dir__(self): @@ -98,6 +96,8 @@ class LiveSettingValidator: @classmethod def _check_grafana_cloud_oncall_token(cls, grafana_oncall_token): + from apps.oss_installation.models import CloudConnector + _, err = CloudConnector.sync_with_cloud(grafana_oncall_token) return err diff --git a/engine/apps/oss_installation/migrations/0001_squashed_initial.py b/engine/apps/oss_installation/migrations/0001_squashed_initial.py index dac55f47..b1a34cbd 100644 --- a/engine/apps/oss_installation/migrations/0001_squashed_initial.py +++ b/engine/apps/oss_installation/migrations/0001_squashed_initial.py @@ -30,4 +30,20 @@ class Migration(migrations.Migration): ('report_sent_at', models.DateTimeField(default=None, null=True)), ], ), + migrations.CreateModel( + name='CloudConnector', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('cloud_url', models.URLField()), + ], + ), + migrations.CreateModel( + name='CloudUserIdentity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('phone_number_verified', models.BooleanField(default=False)), + ('cloud_id', models.CharField(max_length=20)), + ('email', models.EmailField(max_length=254)), + ], + ), ] diff --git a/engine/apps/oss_installation/models/cloud_connector.py b/engine/apps/oss_installation/models/cloud_connector.py index 1434b1ba..39edc18c 100644 --- a/engine/apps/oss_installation/models/cloud_connector.py +++ b/engine/apps/oss_installation/models/cloud_connector.py @@ -5,7 +5,6 @@ import requests from django.db import models, transaction from apps.base.utils import live_settings -from apps.oss_installation.models import CloudHeartbeat from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity from apps.user_management.models import User from settings.base import GRAFANA_CLOUD_ONCALL_API_URL @@ -40,7 +39,7 @@ class CloudConnector(models.Model): try: r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5) if r.status_code == 200: - connector = cls.objects.get_or_create() + connector, _ = cls.objects.get_or_create() connector.cloud_url = r.json()["url"] connector.save() elif r.status_code == 403: @@ -104,9 +103,9 @@ class CloudConnector(models.Model): ) ) - CloudUserIdentity.objects.delete() + CloudUserIdentity.objects.all().delete() CloudUserIdentity.objects.bulk_create(cloud_users_identities_to_create, batch_size=1000) - + sync_status = True return sync_status, error_msg def sync_user_with_cloud(self, user): @@ -151,6 +150,8 @@ class CloudConnector(models.Model): @classmethod def remove_sync(cls): - cls.objects.delete() - CloudUserIdentity.objects.delete() - CloudHeartbeat.objects.delete() + from apps.oss_installation.models import CloudHeartbeat + + cls.objects.all().delete() + CloudUserIdentity.objects.all().delete() + CloudHeartbeat.objects.all().delete() diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 9cbe8980..2b206cac 100644 --- a/engine/apps/oss_installation/views/__init__.py +++ b/engine/apps/oss_installation/views/__init__.py @@ -1,3 +1,2 @@ from .cloud_connection import CloudConnectionView # noqa: F401 -from .cloud_heartbeat import CloudHeartbeatStatusView # noqa: F401 from .cloud_users import CloudUsersView, CloudUserView # noqa: F401 From 5d18e636a2fb2dafe2d4be4fddebe4fd59ea6b13 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 6 Jun 2022 18:08:32 +0400 Subject: [PATCH 013/132] Fix live_settings search --- engine/apps/api/views/live_setting.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index 4c9b7beb..bd0fb4fd 100644 --- a/engine/apps/api/views/live_setting.py +++ b/engine/apps/api/views/live_setting.py @@ -33,7 +33,11 @@ class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet): def get_queryset(self): LiveSetting.populate_settings_if_needed() - return LiveSetting.objects.filter(name__in=LiveSetting.AVAILABLE_NAMES).order_by("name") + queryset = LiveSetting.objects.filter(name__in=LiveSetting.AVAILABLE_NAMES).order_by("name") + search = self.request.query_params.get("search", None) + if search: + queryset = queryset.filter(name=search) + return queryset def perform_update(self, serializer): new_value = serializer.validated_data["value"] From 5bd16f051b95601c796c072ef87325fca0085bab Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Mon, 6 Jun 2022 17:00:32 +0200 Subject: [PATCH 014/132] endpoints WIP --- grafana-plugin/src/models/cloud/cloud.ts | 18 ++++++++++++++++++ .../src/pages/cloud/CloudPage.module.css | 4 ++++ grafana-plugin/src/pages/cloud/CloudPage.tsx | 5 +++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts index d5c40049..f8e09e71 100644 --- a/grafana-plugin/src/models/cloud/cloud.ts +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -56,4 +56,22 @@ export class CloudStore extends BaseStore { this.searchResult.results.map((cloud_user_id: Cloud['id']) => this.items?.[cloud_user_id]), }; } + + async syncCloudUsers() { + return await makeRequest(`${this.path}sync_with_cloud`, { method: 'POST' }); + } + + async getCloudConnectionStatus() { + return await makeRequest(`/cloud_connection/`, { method: 'GET' }); + } + + @action + async connectToCloud(token: string) { + return await makeRequest(`/live_settings/`, { method: 'PUT', params: { token } }); + } + + @action + async disconnectToCloud() { + return await makeRequest(`/live_settings/`, { method: 'DELETE' }); + } } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.module.css b/grafana-plugin/src/pages/cloud/CloudPage.module.css index 387b4c57..9597b6ab 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.module.css +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -19,6 +19,10 @@ width: 100%; } +.cloud-page-title { + margin-top: 24px; +} + .cloud-oncall-name { color: #f55f3e; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index e92d849f..21be94f3 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -50,12 +50,13 @@ const CloudPage = (props: CloudPageProps) => { const disconnectCloudOncall = () => { console.log('disconnected'); setCloudIsConnected(false); + store.cloudStore.disconnectToCloud(); }; const connectToCloud = () => { - console.log('CONNECT TO CLOUD'); setCloudIsConnected(true); setShowConfirmationModal(false); + store.cloudStore.connectToCloud(cloudApiKey); }; const syncUsers = () => { @@ -146,7 +147,7 @@ const CloudPage = (props: CloudPageProps) => { return (
- + Connect Open Source OnCall and Cloud OnCall From 5a0150192ee71e22811144f99bdb7c557fd52177 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 6 Jun 2022 19:05:50 +0400 Subject: [PATCH 015/132] Push migration --- .../migrations/0002_auto_20220604_1008.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 engine/apps/twilioapp/migrations/0002_auto_20220604_1008.py diff --git a/engine/apps/twilioapp/migrations/0002_auto_20220604_1008.py b/engine/apps/twilioapp/migrations/0002_auto_20220604_1008.py new file mode 100644 index 00000000..cddd898c --- /dev/null +++ b/engine/apps/twilioapp/migrations/0002_auto_20220604_1008.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.5 on 2022-06-04 10:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twilioapp', '0001_squashed_initial'), + ] + + operations = [ + migrations.AddField( + model_name='phonecall', + name='grafana_cloud_notification', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='smsmessage', + name='grafana_cloud_notification', + field=models.BooleanField(default=False), + ), + ] From 9f8989726476b7a8dff41ec1abd7fb686e2c0763 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 6 Jun 2022 12:29:25 -0300 Subject: [PATCH 016/132] Drop data migrations setting up demo tokens --- ...03_squashed_create_demo_token_instances.py | 178 ------------------ ...03_squashed_create_demo_token_instances.py | 40 ---- ...03_squashed_create_demo_token_instances.py | 74 -------- ...03_squashed_create_demo_token_instances.py | 47 ----- ...02_squashed_create_demo_token_instances.py | 51 ----- 5 files changed, 390 deletions(-) delete mode 100644 engine/apps/alerts/migrations/0003_squashed_create_demo_token_instances.py delete mode 100644 engine/apps/auth_token/migrations/0003_squashed_create_demo_token_instances.py delete mode 100644 engine/apps/base/migrations/0003_squashed_create_demo_token_instances.py delete mode 100644 engine/apps/slack/migrations/0003_squashed_create_demo_token_instances.py delete mode 100644 engine/apps/user_management/migrations/0002_squashed_create_demo_token_instances.py diff --git a/engine/apps/alerts/migrations/0003_squashed_create_demo_token_instances.py b/engine/apps/alerts/migrations/0003_squashed_create_demo_token_instances.py deleted file mode 100644 index 5729cbd6..00000000 --- a/engine/apps/alerts/migrations/0003_squashed_create_demo_token_instances.py +++ /dev/null @@ -1,178 +0,0 @@ -# Generated by Django 3.2.5 on 2021-08-04 10:42 - -import sys -from django.db import migrations -from django.utils import timezone, dateparse -from apps.alerts.models.alert_receive_channel import number_to_smiles_translator -from apps.public_api import constants as public_api_constants - - -TYPE_SINGLE_EVENT = 0 -TYPE_RECURRENT_EVENT = 1 -FREQUENCY_WEEKLY = 1 -SOURCE_TERRAFORM = 3 -STEP_WAIT = 0 -STEP_NOTIFY_USERS_QUEUE = 12 -SOURCE_WEB = 1 - - -def create_demo_token_instances(apps, schema_editor): - if not (len(sys.argv) > 1 and sys.argv[1] == 'test'): - User = apps.get_model('user_management', 'User') - Organization = apps.get_model('user_management', 'Organization') - AlertReceiveChannel = apps.get_model('alerts', 'AlertReceiveChannel') - EscalationChain = apps.get_model('alerts', 'EscalationChain') - ChannelFilter = apps.get_model('alerts', 'ChannelFilter') - EscalationPolicy = apps.get_model('alerts', 'EscalationPolicy') - OnCallScheduleICal = apps.get_model('schedules', 'OnCallScheduleICal') - AlertGroup = apps.get_model('alerts', 'AlertGroup') - Alert = apps.get_model('alerts', 'Alert') - CustomButton = apps.get_model("alerts", "CustomButton") - CustomOnCallShift = apps.get_model('schedules', 'CustomOnCallShift') - - organization = Organization.objects.get(public_primary_key=public_api_constants.DEMO_ORGANIZATION_ID) - user = User.objects.get(public_primary_key=public_api_constants.DEMO_USER_ID) - - alert_receive_channel, _ = AlertReceiveChannel.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_INTEGRATION_ID, - defaults=dict( - integration=0, - author=user, - organization=organization, - smile_code=number_to_smiles_translator(0) - ) - ) - escalation_chain, _ = EscalationChain.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_ESCALATION_CHAIN_ID, - defaults=dict( - name="default", - organization=organization, - ) - ) - - channel_filter_1, _ = ChannelFilter.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_ROUTE_ID_1, - defaults=dict( - alert_receive_channel=alert_receive_channel, - slack_channel_id=public_api_constants.DEMO_SLACK_CHANNEL_FOR_ROUTE_ID, - filtering_term='us-(east|west)', - order=0, - escalation_chain=escalation_chain, - ) - ) - ChannelFilter.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_ROUTE_ID_2, - defaults=dict( - alert_receive_channel=alert_receive_channel, - slack_channel_id=public_api_constants.DEMO_SLACK_CHANNEL_FOR_ROUTE_ID, - filtering_term='.*', - order=1, - is_default=True, - escalation_chain=escalation_chain, - ) - ) - - EscalationPolicy.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_ESCALATION_POLICY_ID_1, - defaults=dict( - step=STEP_WAIT, - wait_delay=timezone.timedelta(minutes=1), - order=0, - escalation_chain=escalation_chain, - ) - ) - - escalation_policy_1, _ = EscalationPolicy.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_ESCALATION_POLICY_ID_2, - defaults=dict( - step=STEP_NOTIFY_USERS_QUEUE, - order=1, - escalation_chain=escalation_chain, - ) - ) - escalation_policy_1.notify_to_users_queue.add(user) - - schedule, _ = OnCallScheduleICal.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_SCHEDULE_ID_ICAL, - defaults=dict( - organization=organization, - name=public_api_constants.DEMO_SCHEDULE_NAME_ICAL, - ical_url_overrides=public_api_constants.DEMO_SCHEDULE_ICAL_URL_OVERRIDES, - channel=public_api_constants.DEMO_SLACK_CHANNEL_SLACK_ID, - ) - ) - - alert_group, _ = AlertGroup.all_objects.get_or_create( - public_primary_key=public_api_constants.DEMO_INCIDENT_ID, - defaults=dict( - channel=alert_receive_channel, - channel_filter=channel_filter_1, - resolved=True, - resolved_at=dateparse.parse_datetime(public_api_constants.DEMO_INCIDENT_RESOLVED_AT), - ) - ) - alert_group.started_at = dateparse.parse_datetime(public_api_constants.DEMO_INCIDENT_CREATED_AT) - alert_group.save(update_fields=['started_at']) - - for id, created_at in public_api_constants.DEMO_ALERT_IDS: - alert, _ = Alert.objects.get_or_create( - public_primary_key=id, - defaults=dict( - group=alert_group, - raw_request_data=public_api_constants.DEMO_ALERT_PAYLOAD, - title='Memory above 90% threshold', - ) - ) - alert.created_at = dateparse.parse_datetime(created_at) - alert.save(update_fields=['created_at']) - - CustomButton.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_CUSTOM_ACTION_ID, - defaults=dict( - name=public_api_constants.DEMO_CUSTOM_ACTION_NAME, - organization=organization, - ) - ) - - on_call_shift_1, _ = CustomOnCallShift.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_ON_CALL_SHIFT_ID_1, - defaults=dict( - type=TYPE_SINGLE_EVENT, - organization=organization, - name=public_api_constants.DEMO_ON_CALL_SHIFT_NAME_1, - start=dateparse.parse_datetime(public_api_constants.DEMO_ON_CALL_SHIFT_START_1), - duration=timezone.timedelta(seconds=public_api_constants.DEMO_ON_CALL_SHIFT_DURATION), - ) - ) - - on_call_shift_1.users.add(user) - - on_call_shift_2, _ = CustomOnCallShift.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_ON_CALL_SHIFT_ID_2, - defaults=dict( - type=TYPE_RECURRENT_EVENT, - organization=organization, - name=public_api_constants.DEMO_ON_CALL_SHIFT_NAME_2, - start=dateparse.parse_datetime(public_api_constants.DEMO_ON_CALL_SHIFT_START_2), - duration=timezone.timedelta(seconds=public_api_constants.DEMO_ON_CALL_SHIFT_DURATION), - frequency=FREQUENCY_WEEKLY, - interval=2, - by_day=public_api_constants.DEMO_ON_CALL_SHIFT_BY_DAY, - source=SOURCE_TERRAFORM, - ) - ) - - on_call_shift_2.users.add(user) - - -class Migration(migrations.Migration): - - dependencies = [ - ('alerts', '0002_squashed_initial'), - ('user_management', '0002_squashed_create_demo_token_instances'), - ('schedules', '0002_squashed_initial'), - ] - - operations = [ - migrations.RunPython(create_demo_token_instances, migrations.RunPython.noop) - ] diff --git a/engine/apps/auth_token/migrations/0003_squashed_create_demo_token_instances.py b/engine/apps/auth_token/migrations/0003_squashed_create_demo_token_instances.py deleted file mode 100644 index 225e0fcb..00000000 --- a/engine/apps/auth_token/migrations/0003_squashed_create_demo_token_instances.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 3.2.5 on 2021-08-04 13:02 - -import sys -from django.db import migrations - -from apps.auth_token import constants -from apps.auth_token import crypto -from apps.public_api import constants as public_api_constants - - -def create_demo_token_instances(apps, schema_editor): - if not (len(sys.argv) > 1 and sys.argv[1] == 'test'): - User = apps.get_model('user_management', 'User') - Organization = apps.get_model('user_management', 'Organization') - ApiAuthToken = apps.get_model('auth_token', 'ApiAuthToken') - - organization = Organization.objects.get(public_primary_key=public_api_constants.DEMO_ORGANIZATION_ID) - user = User.objects.get(public_primary_key=public_api_constants.DEMO_USER_ID) - - token_string = crypto.generate_token_string() - digest = crypto.hash_token_string(token_string) - - ApiAuthToken.objects.get_or_create( - name=public_api_constants.DEMO_AUTH_TOKEN, - user=user, - organization=organization, - defaults=dict(token_key=token_string[:constants.TOKEN_KEY_LENGTH], digest=digest) - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ('auth_token', '0002_squashed_initial'), - ('user_management', '0002_squashed_create_demo_token_instances') - ] - - operations = [ - migrations.RunPython(create_demo_token_instances, migrations.RunPython.noop) - ] diff --git a/engine/apps/base/migrations/0003_squashed_create_demo_token_instances.py b/engine/apps/base/migrations/0003_squashed_create_demo_token_instances.py deleted file mode 100644 index a590210a..00000000 --- a/engine/apps/base/migrations/0003_squashed_create_demo_token_instances.py +++ /dev/null @@ -1,74 +0,0 @@ -# Generated by Django 3.2.5 on 2021-08-04 10:45 - -import sys -from django.db import migrations -from django.utils import timezone -from apps.public_api import constants as public_api_constants - - -STEP_WAIT = 0 -STEP_NOTIFY = 1 -NOTIFY_BY_SMS = 1 -NOTIFY_BY_PHONE = 2 -FIVE_MINUTES = timezone.timedelta(minutes=5) - - -def create_demo_token_instances(apps, schema_editor): - if not (len(sys.argv) > 1 and sys.argv[1] == 'test'): - User = apps.get_model('user_management', 'User') - UserNotificationPolicy = apps.get_model("base", "UserNotificationPolicy") - - user = User.objects.get(public_primary_key=public_api_constants.DEMO_USER_ID) - - UserNotificationPolicy.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_1, - defaults=dict( - important=False, - user=user, - notify_by=NOTIFY_BY_SMS, - step=STEP_NOTIFY, - order=0, - ) - ) - UserNotificationPolicy.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_2, - defaults=dict( - important=False, - user=user, - step=STEP_WAIT, - wait_delay=FIVE_MINUTES, - order=1, - ) - ) - UserNotificationPolicy.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_3, - defaults=dict( - important=False, - user=user, - step=STEP_NOTIFY, - notify_by=NOTIFY_BY_PHONE, - order=2, - ) - ) - - UserNotificationPolicy.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_4, - defaults=dict( - important=True, - user=user, - notify_by=NOTIFY_BY_PHONE, - order=0, - ) - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ('base', '0002_squashed_initial'), - ('user_management', '0002_squashed_create_demo_token_instances') - ] - - operations = [ - migrations.RunPython(create_demo_token_instances, migrations.RunPython.noop) - ] diff --git a/engine/apps/slack/migrations/0003_squashed_create_demo_token_instances.py b/engine/apps/slack/migrations/0003_squashed_create_demo_token_instances.py deleted file mode 100644 index ae3368f1..00000000 --- a/engine/apps/slack/migrations/0003_squashed_create_demo_token_instances.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 3.2.5 on 2021-08-04 10:51 - -import sys -from django.db import migrations -from apps.public_api import constants as public_api_constants - - -def create_demo_token_instances(apps, schema_editor): - if not (len(sys.argv) > 1 and sys.argv[1] == 'test'): - SlackUserIdentity = apps.get_model('slack', 'SlackUserIdentity') - SlackTeamIdentity = apps.get_model('slack', 'SlackTeamIdentity') - SlackChannel = apps.get_model('slack', 'SlackChannel') - SlackUserGroup = apps.get_model("slack", "SlackUserGroup") - - slack_team_identity, _ = SlackTeamIdentity.objects.get_or_create( - slack_id=public_api_constants.DEMO_SLACK_TEAM_ID, - ) - SlackUserIdentity.objects.get_or_create( - slack_id=public_api_constants.DEMO_SLACK_USER_ID, - slack_team_identity=slack_team_identity, - ) - - SlackChannel.objects.get_or_create( - name=public_api_constants.DEMO_SLACK_CHANNEL_NAME, - slack_id=public_api_constants.DEMO_SLACK_CHANNEL_SLACK_ID, - slack_team_identity=slack_team_identity, - ) - - SlackUserGroup.objects.get_or_create( - slack_team_identity=slack_team_identity, - slack_id=public_api_constants.DEMO_SLACK_USER_GROUP_SLACK_ID, - public_primary_key=public_api_constants.DEMO_SLACK_USER_GROUP_ID, - name=public_api_constants.DEMO_SLACK_USER_GROUP_NAME, - handle=public_api_constants.DEMO_SLACK_USER_GROUP_HANDLE, - is_active=True, - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ('slack', '0002_squashed_initial'), - ] - - operations = [ - migrations.RunPython(create_demo_token_instances, migrations.RunPython.noop) - ] diff --git a/engine/apps/user_management/migrations/0002_squashed_create_demo_token_instances.py b/engine/apps/user_management/migrations/0002_squashed_create_demo_token_instances.py deleted file mode 100644 index 8b8f932c..00000000 --- a/engine/apps/user_management/migrations/0002_squashed_create_demo_token_instances.py +++ /dev/null @@ -1,51 +0,0 @@ -# Generated by Django 3.2.5 on 2021-08-04 10:46 - -import sys -from django.db import migrations -from apps.public_api import constants as public_api_constants -from common.constants.role import Role - - -def create_demo_token_instances(apps, schema_editor): - if not (len(sys.argv) > 1 and sys.argv[1] == 'test'): - SlackUserIdentity = apps.get_model('slack', 'SlackUserIdentity') - SlackTeamIdentity = apps.get_model('slack', 'SlackTeamIdentity') - User = apps.get_model('user_management', 'User') - Organization = apps.get_model('user_management', 'Organization') - - slack_team_identity = SlackTeamIdentity.objects.get(slack_id=public_api_constants.DEMO_SLACK_TEAM_ID) - slack_user_identity = SlackUserIdentity.objects.get( - slack_id=public_api_constants.DEMO_SLACK_USER_ID, - slack_team_identity=slack_team_identity, - ) - - organization, _ = Organization.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_ORGANIZATION_ID, - defaults=dict( - slack_team_identity=slack_team_identity, - org_id=0, stack_id=0, - ) - ) - User.objects.get_or_create( - public_primary_key=public_api_constants.DEMO_USER_ID, - defaults=dict( - username=public_api_constants.DEMO_USER_USERNAME, - email=public_api_constants.DEMO_USER_EMAIL, - organization=organization, - role=Role.ADMIN, - slack_user_identity=slack_user_identity, - user_id=0, - ) - ) - - -class Migration(migrations.Migration): - - dependencies = [ - ('user_management', '0001_squashed_initial'), - ('slack', '0003_squashed_create_demo_token_instances'), - ] - - operations = [ - migrations.RunPython(create_demo_token_instances, migrations.RunPython.noop) - ] From b3fed35550454226a7ed9f80dff2a1333fc9fa80 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Mon, 6 Jun 2022 10:13:17 -0600 Subject: [PATCH 017/132] Fix typo in alertgroups url --- grafana-plugin/src/models/alertgroup/alertgroup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafana-plugin/src/models/alertgroup/alertgroup.ts b/grafana-plugin/src/models/alertgroup/alertgroup.ts index 684948a1..ba284236 100644 --- a/grafana-plugin/src/models/alertgroup/alertgroup.ts +++ b/grafana-plugin/src/models/alertgroup/alertgroup.ts @@ -68,7 +68,7 @@ export class AlertGroupStore extends BaseStore { constructor(rootStore: RootStore) { super(rootStore); - this.path = '/alertgroups1/'; + this.path = '/alertgroups/'; } async attachAlert(pk: Alert['pk'], rootPk: Alert['pk']) { From dbaffd86f529bcb50fc052e67bd2d89329e951b2 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Mon, 6 Jun 2022 11:02:09 -0600 Subject: [PATCH 018/132] Revert message on alert group page while loading --- grafana-plugin/src/pages/incidents/Incidents.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/grafana-plugin/src/pages/incidents/Incidents.tsx b/grafana-plugin/src/pages/incidents/Incidents.tsx index 9ba1dd71..6e8f3f72 100644 --- a/grafana-plugin/src/pages/incidents/Incidents.tsx +++ b/grafana-plugin/src/pages/incidents/Incidents.tsx @@ -302,12 +302,11 @@ class Incidents extends React.Component (results && results.some((alert: AlertType) => alert.undoAction)) || Object.keys(affectedRows).length ); - console.log('results', results); return (
{this.renderBulkActions()} Date: Mon, 6 Jun 2022 12:04:31 -0600 Subject: [PATCH 019/132] Fix too many tasks being created for create contact points (#12) Fix too many tasks being created for create_contact_points_for_datasource task Co-authored-by: Julia --- .../grafana_alerting_sync.py | 9 ++-- engine/apps/alerts/tasks/__init__.py | 1 + .../create_contact_points_for_datasource.py | 42 +++++++++++++++++-- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py b/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py index 7bfcbdef..a9ca08fb 100644 --- a/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py +++ b/engine/apps/alerts/grafana_alerting_sync_manager/grafana_alerting_sync.py @@ -5,7 +5,7 @@ from typing import Optional from django.apps import apps from rest_framework import status -from apps.alerts.tasks import create_contact_points_for_datasource +from apps.alerts.tasks import schedule_create_contact_points_for_datasource from apps.grafana_plugin.helpers import GrafanaAPIClient logger = logging.getLogger(__name__) @@ -77,16 +77,15 @@ class GrafanaAlertingSyncManager: # sync other datasource for datasource in datasources: if datasource["type"] == GrafanaAlertingSyncManager.ALERTING_DATASOURCE: - if self.create_contact_point(datasource) is None: + contact_point = self.create_contact_point(datasource) + if contact_point is None: # Failed to create contact point duo to getting wrong alerting config. It is expected behaviour. # Add datasource to list and retry to create contact point for it async datasources_to_create.append(datasource) if datasources_to_create: # create other contact points async - create_contact_points_for_datasource.apply_async( - (self.alert_receive_channel.pk, datasources_to_create), - ) + schedule_create_contact_points_for_datasource(self.alert_receive_channel.pk, datasources_to_create) else: self.alert_receive_channel.is_finished_alerting_setup = True self.alert_receive_channel.save(update_fields=["is_finished_alerting_setup"]) diff --git a/engine/apps/alerts/tasks/__init__.py b/engine/apps/alerts/tasks/__init__.py index 8e0e994f..3ff8501e 100644 --- a/engine/apps/alerts/tasks/__init__.py +++ b/engine/apps/alerts/tasks/__init__.py @@ -4,6 +4,7 @@ from .calculcate_escalation_finish_time import calculate_escalation_finish_time from .call_ack_url import call_ack_url # noqa: F401 from .check_escalation_finished import check_escalation_finished_task # noqa: F401 from .create_contact_points_for_datasource import create_contact_points_for_datasource # noqa: F401 +from .create_contact_points_for_datasource import schedule_create_contact_points_for_datasource # noqa: F401 from .custom_button_result import custom_button_result # noqa: F401 from .delete_alert_group import delete_alert_group # noqa: F401 from .distribute_alert import distribute_alert # noqa: F401 diff --git a/engine/apps/alerts/tasks/create_contact_points_for_datasource.py b/engine/apps/alerts/tasks/create_contact_points_for_datasource.py index f3dc3f4b..a447a39c 100644 --- a/engine/apps/alerts/tasks/create_contact_points_for_datasource.py +++ b/engine/apps/alerts/tasks/create_contact_points_for_datasource.py @@ -1,9 +1,32 @@ +import logging + +from celery.utils.log import get_task_logger from django.apps import apps +from django.core.cache import cache from rest_framework import status from apps.grafana_plugin.helpers import GrafanaAPIClient from common.custom_celery_tasks import shared_dedicated_queue_retry_task +logger = get_task_logger(__name__) +logger.setLevel(logging.DEBUG) + + +def get_cache_key_create_contact_points_for_datasource(alert_receive_channel_id): + CACHE_KEY_PREFIX = "create_contact_points_for_datasource" + return f"{CACHE_KEY_PREFIX}_{alert_receive_channel_id}" + + +@shared_dedicated_queue_retry_task +def schedule_create_contact_points_for_datasource(alert_receive_channel_id, datasource_list): + CACHE_LIFETIME = 600 + START_TASK_DELAY = 3 + task = create_contact_points_for_datasource.apply_async( + args=[alert_receive_channel_id, datasource_list], countdown=START_TASK_DELAY + ) + cache_key = get_cache_key_create_contact_points_for_datasource(alert_receive_channel_id) + cache.set(cache_key, task.id, timeout=CACHE_LIFETIME) + @shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=10) def create_contact_points_for_datasource(alert_receive_channel_id, datasource_list): @@ -11,6 +34,11 @@ def create_contact_points_for_datasource(alert_receive_channel_id, datasource_li Try to create contact points for other datasource. Restart task for datasource, for which contact point was not created. """ + cache_key = get_cache_key_create_contact_points_for_datasource(alert_receive_channel_id) + cached_task_id = cache.get(cache_key) + current_task_id = create_contact_points_for_datasource.request.id + if cached_task_id is not None and current_task_id != cached_task_id: + return AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel") @@ -21,7 +49,7 @@ def create_contact_points_for_datasource(alert_receive_channel_id, datasource_li api_token=alert_receive_channel.organization.api_token, ) # list of datasource for which contact point creation was failed - datasource_to_create = [] + datasources_to_create = [] for datasource in datasource_list: contact_point = None config, response_info = client.get_alerting_config(datasource["id"]) @@ -29,16 +57,22 @@ def create_contact_points_for_datasource(alert_receive_channel_id, datasource_li if response_info.get("status_code") == status.HTTP_404_NOT_FOUND: client.get_alertmanager_status_with_config(datasource["id"]) contact_point = alert_receive_channel.grafana_alerting_sync_manager.create_contact_point(datasource) + elif response_info.get("status_code") == status.HTTP_400_BAD_REQUEST: + logger.warning( + f"Failed to create contact point for integration {alert_receive_channel_id}, " + f"datasource info: {datasource}; response: {response_info}" + ) + continue else: contact_point = alert_receive_channel.grafana_alerting_sync_manager.create_contact_point(datasource) if contact_point is None: # Failed to create contact point duo to getting wrong alerting config. # Add datasource to list and retry to create contact point for it again - datasource_to_create.append(datasource) + datasources_to_create.append(datasource) # if some contact points were not created, restart task for them - if datasource_to_create: - create_contact_points_for_datasource.apply_async((alert_receive_channel_id, datasource_to_create), countdown=5) + if datasources_to_create: + schedule_create_contact_points_for_datasource(alert_receive_channel_id, datasources_to_create) else: alert_receive_channel.is_finished_alerting_setup = True alert_receive_channel.save(update_fields=["is_finished_alerting_setup"]) From 3201a805f19370e03cacd0d62e02fb3731b550eb Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Mon, 6 Jun 2022 21:54:15 +0300 Subject: [PATCH 020/132] Updated GOVERNANCE, added Code Of Conduct --- CODE_OF_CONDUCT.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ GOVERNANCE.md | 27 +++++++++++---------------- MAINTAINERS.md | 5 ++--- 3 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..3d4caa4f --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at conduct@grafana.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 830980c8..6b837d68 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -22,7 +22,7 @@ The OnCall developers and community are expected to follow the values defined in ## Projects -Each project must have a [`MAINTAINERS.md`][maintainers] file with at least one maintainer. Where a project has a release process, access and documentation should be such that more than one person can perform a release. Releases should be announced on the [announcemount][announce] and [users][users] mailing lists. Any new projects should be first proposed on the [team mailing list][team] following the voting procedures listed below. +Each project must have a [`MAINTAINERS.md`][maintainers] file with at least one maintainer. Where a project has a release process, access and documentation should be such that more than one person can perform a release. Releases should be announced on the [announcements][https://github.com/grafana/oncall/discussions/categories/announcements] category at the GitHub Discussions. Any new projects should be first proposed on the [team mailing list][team] following the voting procedures listed below. ## Decision making @@ -48,14 +48,16 @@ In case a member leaves, the [offboarding](#offboarding) procedure is applied. The current team members are: -- Eve Meelan — [@Eve832](https://github.com/Eve832) ([Grafana Labs](https://grafana.com/)) - Ildar Iskhakov — [@iskhakov](https://github.com/iskhakov) ([Grafana Labs](https://grafana.com/)) - Innokentii Konstantinov — [@Konstantinov-Innokentii](https://github.com/Konstantinov-Innokentii) ([Grafana Labs](https://grafana.com/)) - Matías Bordese — [@matiasb](https://github.com/matiasb) ([Grafana Labs](https://grafana.com/)) -- Matvey Kuku — [@Matvey-Kuk](https://github.com/Matvey-Kuk) ([Grafana Labs](https://grafana.com/)) +- Matvey Kukuy — [@Matvey-Kuk](https://github.com/Matvey-Kuk) ([Grafana Labs](https://grafana.com/)) - Michael Derynck — [@mderynck](https://github.com/mderynck) ([Grafana Labs](https://grafana.com/)) - Vadim Stepanov — [@vadimkerr](https://github.com/vadimkerr) ([Grafana Labs](https://grafana.com/)) - Yulia Shanyrova — [@Ukochka](https://github.com/Ukochka) ([Grafana Labs](https://grafana.com/)) +- Maxim Mordasov — [@maskin25](https://github.com/maskin25) ([Grafana Labs](https://grafana.com/)) +- Julia Artyukhina — [@Ferril](https://github.com/Ferril) ([Grafana Labs](https://grafana.com/)) +- Julia Artyukhina — [@Ferril](https://github.com/Ferril) ([Grafana Labs](https://grafana.com/)) Previous team members: @@ -65,7 +67,7 @@ Previous team members: Maintainers lead one or more project(s) or parts thereof and serve as a point of conflict resolution amongst the contributors to this project. Ideally, maintainers are also team members, but exceptions are possible for suitable maintainers that, for whatever reason, are not yet team members. -Changes in maintainership have to be announced on the [developers mailing list][devs]. They are decided by [rough consensus](#consensus) and formalized by changing the [`MAINTAINERS.md`][maintainers] file of the respective repository. +Changes in maintainership have to be announced on the [announcemount][https://github.com/grafana/oncall/discussions/categories/announcements] category at the GitHub Discussions. They are decided by [rough consensus](#consensus) and formalized by changing the [`MAINTAINERS.md`][maintainers] file of the respective repository. Maintainers are granted commit rights to all projects covered by this governance. @@ -75,7 +77,7 @@ A project may have multiple maintainers, as long as the responsibilities are cle ### Technical decisions -Technical decisions that only affect a single project are made informally by the maintainer of this project, and [rough consensus](#consensus) is assumed. Technical decisions that span multiple parts of the project should be discussed and made on the [developer mailing list][devs]. +Technical decisions that only affect a single project are made informally by the maintainer of this project, and [rough consensus](#consensus) is assumed. Technical decisions that span multiple parts of the project should be discussed and made on the the [GitHub Discussions][https://github.com/grafana/oncall/discussions]. Decisions are usually made by [rough consensus](#consensus). If no consensus can be reached, the matter may be resolved by [majority vote](#majority-vote). @@ -85,7 +87,7 @@ Changes to this document are made by Grafana Labs. ### Other matters -Any matter that needs a decision may be called to a vote by any member if they deem it necessary. For private or personnel matters, discussion and voting takes place on the [team mailing list][team], otherwise on the [developer mailing list][devs]. +Any matter that needs a decision may be called to a vote by any member if they deem it necessary. For private or personnel matters, discussion and voting takes place on the [team mailing list][team], otherwise on the [GitHub Discussions][https://github.com/grafana/oncall/discussions]. ## Voting @@ -97,7 +99,7 @@ For all votes, voting must be open for at least one week. The end date should be In all cases, all and only [team members](#team-members) are eligible to vote, with the sole exception of the forced removal of a team member, in which said member is not eligible to vote. -Discussion and votes on personnel matters (including but not limited to team membership and maintainership) are held in private on the [team mailing list][team]. All other discussion and votes are held in public on the [developer mailing list][devs]. +Discussion and votes on personnel matters (including but not limited to team membership and maintainership) are held in private on the [team mailing list][team]. All other discussion and votes are held in public on the [GitHub Discussions][https://github.com/grafana/oncall/discussions]. For public discussions, anyone interested is encouraged to participate. Formal power to object or vote is limited to [team members](#team-members). @@ -105,7 +107,7 @@ For public discussions, anyone interested is encouraged to participate. Formal p The default decision making mechanism for the OnCall project is [rough][rough] consensus. This means that any decision on technical issues is considered supported by the [team][team] as long as nobody objects or the objection has been considered but not necessarily accommodated. -Silence on any consensus decision is implicit agreement and equivalent to explicit agreement. Explicit agreement may be stated at will. Decisions may, but do not need to be called out and put up for decision on the [developers mailing list][devs] at any time and by anyone. +Silence on any consensus decision is implicit agreement and equivalent to explicit agreement. Explicit agreement may be stated at will. Decisions may, but do not need to be called out and put up for decision on the [GitHub Discussions][https://github.com/grafana/oncall/discussions] at any time and by anyone. Consensus decisions can never override or go against the spirit of an earlier explicit vote. @@ -140,7 +142,7 @@ If there are multiple alternatives, members may vote for one or more alternative The new member is - added to the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. -- announced on the [developers mailing list][devs] by an existing team member. Ideally, the new member replies in this thread, acknowledging team membership. +- announced on the [GitHub Discussions][https://github.com/grafana/oncall/discussions] by an existing team member. Ideally, the new member replies in this thread, acknowledging team membership. - added to the projects with commit rights. - added to the [team mailing list][team]. @@ -155,10 +157,3 @@ The ex-member is - added to a list of previous members if they so choose. If needed, we reserve the right to publicly announce removal. - -[announce]: TODO -[coc]: https://github.com/grafana/oncall/blob/master/CODE_OF_CONDUCT.md -[devs]: TODO -[maintainers]: https://github.com/grafana/oncall/blob/master/MAINTAINERS.md -[rough]: https://tools.ietf.org/html/rfc7282 -[team]: TODO diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 97c9ba33..bd9b78f3 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,12 +1,11 @@ The following are the main/default maintainers: - Ildar Iskhakov — [@iskhakov](https://github.com/iskhakov) ([Grafana Labs](https://grafana.com/)) -- Matvey Kuku — [@Matvey-Kuk](https://github.com/Matvey-Kuk) ([Grafana Labs](https://grafana.com/)) +- Matvey Kukuy — [@Matvey-Kuk](https://github.com/Matvey-Kuk) ([Grafana Labs](https://grafana.com/)) Some parts of the codebase have other maintainers, the package paths also include all sub-packages: -- `docs`: - - Eve Meelan — [@Eve832](https://github.com/Eve832) ([Grafana Labs](https://grafana.com/)) +n/a For the sake of brevity, not all subtrees are explicitly listed. Due to the size of this repository, the natural changes in focus of maintainers over time, From 4d4bbd7297350a99495eb1fd3053e36e2916f3e8 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 7 Jun 2022 02:03:14 +0300 Subject: [PATCH 021/132] Small polishing, links for OSS config page --- DEVELOPER.md | 4 +- grafana-plugin/src/GrafanaPluginRootPage.tsx | 2 +- .../PluginConfigPage/PluginConfigPage.tsx | 53 +++++++++---------- 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index fd4da888..17d00475 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -59,7 +59,7 @@ mkdir sqlite_data # Migrate the DB: python manage.py migrate -# Create user for django admin panel: +# Create user for django admin panel (if you need it): python manage.py createsuperuser ``` @@ -69,7 +69,7 @@ python manage.py createsuperuser # Http server: python manage.py runserver -# Worker for background tasks(run it in the parallel terminal, don't forget to export .env there) +# Worker for background tasks (run it in the parallel terminal, don't forget to export .env there) python manage.py start_celery # Additionally you could launch the worker with periodic tasks launcher (99% you don't need this) diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index a3276a5f..5eb10a1f 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -48,7 +48,7 @@ const RootWithLoader = observer((props: AppRootProps) => { } else if (store.isUserAnonymous) { text = '😞 Unfortunately Grafana OnCall is available for authorized users only, please sign in to proceed.'; } else if (store.retrySync) { - text = `🚫 OnCall took too many tries to synchronize`; + text = `🚫 OnCall took too many tries to synchronize... Are background workers up and running?`; } return ( diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index e3ccfc4c..15fe0581 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -189,7 +189,9 @@ export const PluginConfigPage = (props: Props) => { if (counter >= 5) { clearInterval(interval); - setPluginStatusMessage(`OnCall took too many tries to synchronize.`); + setPluginStatusMessage( + `OnCall took too many tries to synchronize. Did you launch Celery workers? Background workers should perform synchronization, not web server.` + ); setRetrySync(true); setPluginStatusOk(false); setPluginConfigLoading(false); @@ -212,7 +214,7 @@ export const PluginConfigPage = (props: Props) => { Configure Grafana OnCall {pluginStatusOk && (

- Configuration was sucessfully created. Now you can find Grafana OnCall on right toolbar.{' '} + Configuration was successfully created. Now you can find Grafana OnCall on right toolbar.{' '} Grafana OnCall Logo

)} @@ -242,12 +244,28 @@ export const PluginConfigPage = (props: Props) => { Configure Grafana OnCall

This page will help you to connect OnCall backend and OnCall Grafana plugin 👋

-

1. Grafana OnCall is a Grafana plugin and backend. Run backend

+

+ + - Talk to the OnCall team in the #grafana-oncall channel at{' '} + + Slack + +
- Ask questions at{' '} + + GitHub Discussions + {' '} + or file bugs at{' '} + + GitHub Issues + +
+

+

1. Launch backend

Run production backend using{' '} - - this instructions at our GitHub + + this instructions at our GitHub , @@ -267,27 +285,6 @@ export const PluginConfigPage = (props: Props) => { - - - Need help? -
- 1. Talk to the developers in the #grafana-oncall channel at{' '} - - Slack - -
- 2. Search for issues or create a new one in the{' '} - - GitHub - -
- - } - />

2. Conect the backend and the plugin

{'Plugin <-> backend connection status'}

@@ -301,7 +298,7 @@ Seek for such a line: “Your invite token: <> , use it in the Graf > <> - + How to re-issue the invite token? @@ -311,7 +308,7 @@ Seek for such a line: “Your invite token: <> , use it in the Graf Date: Tue, 7 Jun 2022 11:23:41 +0200 Subject: [PATCH 022/132] endpoints WIP --- .../CloudPhoneSettings/CloudPhoneSettings.tsx | 8 +- grafana-plugin/src/icons/cross-circled.svg | 8 ++ grafana-plugin/src/icons/heart-line.svg | 24 +++++ grafana-plugin/src/icons/index.tsx | 36 +++++++ grafana-plugin/src/models/cloud/cloud.ts | 21 ++--- .../src/models/cloud/cloud.types.ts | 7 +- .../src/pages/cloud/CloudPage.module.css | 22 +++++ grafana-plugin/src/pages/cloud/CloudPage.tsx | 94 +++++++++++++------ 8 files changed, 174 insertions(+), 46 deletions(-) create mode 100644 grafana-plugin/src/icons/cross-circled.svg create mode 100644 grafana-plugin/src/icons/heart-line.svg diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index 08f94cd6..dd833057 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -11,6 +11,7 @@ import Text from 'components/Text/Text'; import WithConfirm from 'components/WithConfirm/WithConfirm'; import { User as UserType } from 'models/user/user.types'; import { WithStoreProps } from 'state/types'; +import { useStore } from 'state/useStore'; import { withMobXProviderContext } from 'state/withStore'; import styles from './CloudPhoneSettings.module.css'; @@ -20,21 +21,22 @@ const cx = cn.bind(styles); interface CloudPhoneSettingsProps extends WithStoreProps {} const CloudPhoneSettings = (props: CloudPhoneSettingsProps) => { + const store = useStore(); const [isAccountMatched, setIsAccountMatched] = useState(true); const [isPhoneVerified, setIsPhoneVerified] = useState(true); const signUpGrafanaCloud = () => { console.log('Sign UP'); }; - const handleLinkClick = (link: string) => { - getLocationSrv().update({ partial: false, path: link }); + const handleLinkClick = () => { + store.cloudStore.syncCloudUser(store.userStore.currentUserPk); }; return ( OnCall use Grafana Cloud for SMS and phone call notifications - diff --git a/grafana-plugin/src/icons/cross-circled.svg b/grafana-plugin/src/icons/cross-circled.svg new file mode 100644 index 00000000..f468d638 --- /dev/null +++ b/grafana-plugin/src/icons/cross-circled.svg @@ -0,0 +1,8 @@ + + + diff --git a/grafana-plugin/src/icons/heart-line.svg b/grafana-plugin/src/icons/heart-line.svg new file mode 100644 index 00000000..6c063e81 --- /dev/null +++ b/grafana-plugin/src/icons/heart-line.svg @@ -0,0 +1,24 @@ + + + + + + + diff --git a/grafana-plugin/src/icons/index.tsx b/grafana-plugin/src/icons/index.tsx index fc1b0d3a..7b77d8f6 100644 --- a/grafana-plugin/src/icons/index.tsx +++ b/grafana-plugin/src/icons/index.tsx @@ -168,6 +168,42 @@ export const HeartRedIcon = (props: IconProps) => ( ); +export const HeartIcon = (props: IconProps) => ( + + + + + +); + +export const CrossCircleIcon = (props: IconProps) => ( + + + +); + export const GrafanaIcon = (props: IconProps) => ( this.items?.[cloud_user_id]), + results: this.searchResult.results && this.searchResult.results.map((id: Cloud['id']) => this.items?.[id]), }; } async syncCloudUsers() { - return await makeRequest(`${this.path}sync_with_cloud`, { method: 'POST' }); + return await makeRequest(`${this.path}`, { method: 'POST' }); + } + + async syncCloudUser(id: string) { + return await makeRequest(`${this.path}${id}/sync_with_cloud/`, { method: 'POST' }); } async getCloudConnectionStatus() { @@ -66,9 +67,7 @@ export class CloudStore extends BaseStore { } @action - async connectToCloud(token: string) { - return await makeRequest(`/live_settings/`, { method: 'PUT', params: { token } }); - } + async connectToCloud(token: string) {} @action async disconnectToCloud() { diff --git a/grafana-plugin/src/models/cloud/cloud.types.ts b/grafana-plugin/src/models/cloud/cloud.types.ts index 2aa411a1..15658b3d 100644 --- a/grafana-plugin/src/models/cloud/cloud.types.ts +++ b/grafana-plugin/src/models/cloud/cloud.types.ts @@ -1,6 +1,9 @@ export interface Cloud { id: string; username: string; - cloud_sync_status?: number; - link?: string; + email: string; + cloud_data?: { + status?: number; + link?: string; + }; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.module.css b/grafana-plugin/src/pages/cloud/CloudPage.module.css index 9597b6ab..ba98f153 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.module.css +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -1,5 +1,7 @@ .info-block { width: 70%; + min-width: 1100px; + padding: 24px; } .warning-message { @@ -19,6 +21,10 @@ width: 100%; } +.user-row { + height: 32px; +} + .cloud-page-title { margin-top: 24px; } @@ -26,3 +32,19 @@ .cloud-oncall-name { color: #f55f3e; } + +.block-icon { + color: var(--secondary-text-color); +} + +.block-button { + margin-top: 24px; +} + +.table-title { + margin-bottom: 16px; +} + +.table-button { + float: right; +} diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index 21be94f3..5aa1b8fa 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -9,7 +9,7 @@ import GTable from 'components/GTable/GTable'; import PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import WithConfirm from 'components/WithConfirm/WithConfirm'; -import { HeartGreenIcon, HeartRedIcon } from 'icons'; +import { CrossCircleIcon, HeartIcon } from 'icons'; import { Cloud } from 'models/cloud/cloud.types'; import { WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; @@ -29,16 +29,19 @@ const CloudPage = (props: CloudPageProps) => { useEffect(() => { store.cloudStore.updateItems(); + store.cloudStore.getCloudConnectionStatus().then((cloudStatus) => { + setCloudIsConnected(cloudStatus.cloud_connection_status); + }); }, []); - const usersCount = 3; const data = [ - { id: 'yshanyrova', username: 'y.shanyrova@grafana.com', cloud_sync_status: 2, link: '/test/abc' }, - { id: 'amixradmin', username: 'amixr-admin@grafana.com', cloud_sync_status: 1, link: '/test/qwerty' }, - { id: 'amixr', username: 'amixr@grafana.com', cloud_sync_status: undefined, link: undefined }, + { id: 'yshanyrova', email: 'y.shanyrova@grafana.com', cloud_data: { status: 2, link: '/test/abc' } }, + { id: 'amixradmin', email: 'amixr-admin@grafana.com', cloud_data: { status: 1, link: '/test/abc' } }, + { id: 'amixr', email: 'amixr@grafana.com', cloud_data: { status: undefined, link: '/test/abc' } }, ]; - // const data = store.cloudStore.getSearchResult(); + // const { count, results } = store.cloudStore.getSearchResult(); + const handleChangeCloudApiKey = useCallback((e) => { setCloudApiKey(e.target.value); }, []); @@ -56,11 +59,12 @@ const CloudPage = (props: CloudPageProps) => { const connectToCloud = () => { setCloudIsConnected(true); setShowConfirmationModal(false); + // store.cloudStore.update('') store.cloudStore.connectToCloud(cloudApiKey); }; const syncUsers = () => { - console.log('Sync Users'); + store.cloudStore.syncCloudUsers(); }; const handleLinkClick = (link: string) => { @@ -68,18 +72,30 @@ const CloudPage = (props: CloudPageProps) => { }; const renderButtons = (user: Cloud) => { - switch (user.cloud_sync_status) { + switch (user?.cloud_data?.status) { case 0: return null; case 1: return ( - + + + ); case 1: return ; case 2: return ; default: - return ; + return ( + + + + ); } }; const renderEmail = (user: Cloud) => { - return {user.username}; + return {user.email}; }; const columns = [ { - width: '5%', + width: '2%', render: renderStatusIcon, key: 'statusIcon', }, { - width: '30%', + width: '28%', render: renderEmail, key: 'email', }, { - width: '35%', + width: '50%', render: renderStatus, key: 'status', }, { - width: '30%', + width: '20%', render: renderButtons, key: 'buttons', align: 'actions', @@ -154,12 +178,12 @@ const CloudPage = (props: CloudPageProps) => { {cloudIsConnected ? ( - Cloud OnCall API key + Cloud OnCall API key Cloud OnCall is sucessfully connected. - @@ -167,7 +191,7 @@ const CloudPage = (props: CloudPageProps) => { ) : ( - Cloud OnCall API key + Cloud OnCall API key @@ -199,7 +223,10 @@ const CloudPage = (props: CloudPageProps) => { - Monitor cloud instance with heartbeat + + + {' '} + Monitor cloud instance with heartbeat Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no @@ -209,6 +236,7 @@ const CloudPage = (props: CloudPageProps) => { - +
+ + + {/* {count ? count : 0} */} + {`3 users matched between OSS and Cloud OnCall`} + + + +
)} rowKey="id" // @ts-ignore From caa6eeb3cf5066e2babf64d55a090f4430fc1fd9 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 7 Jun 2022 10:07:36 -0600 Subject: [PATCH 023/132] Remove branch name from temp plugin file to avoid incompatible characters (#19) Co-authored-by: Michael Derynck --- .drone.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.drone.yml b/.drone.yml index 4fc14748..687dd3e6 100644 --- a/.drone.yml +++ b/.drone.yml @@ -30,8 +30,8 @@ steps: - yarn ci-build:finish - yarn ci-package - cd ci/dist - - zip -r grafana-oncall-app-${DRONE_BRANCH}-${DRONE_BUILD_NUMBER}.zip ./grafana-oncall-app - - if [ -z "$DRONE_TAG" ]; then echo "No tag, skipping archive"; else cp grafana-oncall-app-${DRONE_BRANCH}-${DRONE_BUILD_NUMBER}.zip grafana-oncall-app-${DRONE_TAG}.zip; fi + - zip -r grafana-oncall-app.zip ./grafana-oncall-app + - if [ -z "$DRONE_TAG" ]; then echo "No tag, skipping archive"; else cp grafana-oncall-app.zip grafana-oncall-app-${DRONE_TAG}.zip; fi - name: Publish Plugin to GCS (release) image: plugins/gcs From 591db52ab82dbd8da3bca4a6a0c2cf78e37d9d5f Mon Sep 17 00:00:00 2001 From: Jack Baldry Date: Tue, 7 Jun 2022 16:45:49 +0100 Subject: [PATCH 024/132] Support whitespace in mount source directory Signed-off-by: Jack Baldry --- docs/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Makefile b/docs/Makefile index f66259da..5ddacacf 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -8,4 +8,4 @@ pull: .PHONY: docs docs: pull - docker run -v $(shell pwd)/sources:$(CONTENT_PATH):Z -p $(PORT) --rm -it $(IMAGE) + docker run -v '$(shell pwd)/sources:$(CONTENT_PATH):Z' -p $(PORT) --rm -it $(IMAGE) From 308e59c76985a4a553837e8830dfdad8b38e0404 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 12:07:30 +0400 Subject: [PATCH 025/132] Add disconnect cloud endpoint --- engine/apps/oss_installation/views/cloud_connection.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py index 5fe2ba47..6acbef57 100644 --- a/engine/apps/oss_installation/views/cloud_connection.py +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -1,5 +1,6 @@ from urllib.parse import urljoin +from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -32,3 +33,10 @@ class CloudConnectionView(APIView): if heartbeat is None: return None return urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=integrations1&id={heartbeat.integration_id}") + + def delete(self, request): + connector = CloudConnector.objects.first() + if connector is None: + return Response(status=status.HTTP_404_NOT_FOUND) + connector.remove_sync() + return Response(status=status.HTTP_204_NO_CONTENT) From 72332eddfc0200401f8703d2cc94c03aa6e793fe Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Wed, 8 Jun 2022 11:11:50 +0300 Subject: [PATCH 026/132] Add pymysql, bump django-mysql version --- engine/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/engine/requirements.txt b/engine/requirements.txt index a9dfc03d..1aaf78c5 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -9,7 +9,6 @@ celery==4.3.0 redis==3.2.0 django-celery-results==1.0.4 humanize==0.5.1 -django-mysql==2.4.1 uwsgi==2.0.20 django-cors-headers==3.7.0 django-debug-toolbar==3.2.1 @@ -39,3 +38,5 @@ django-rest-polymorphic==0.1.9 pre-commit==2.15.0 https://github.com/iskhakov/django-push-notifications/archive/refs/tags/2.0.0-hotfix-4.tar.gz django-mirage-field==1.3.0 +django-mysql==4.6.0 +PyMySQL==1.0.2 From 6d7c478bfcf497dacafa6555a226e735e914beec Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 12:23:24 +0400 Subject: [PATCH 027/132] Add periodic task to sync users with cloud --- engine/apps/oss_installation/tasks.py | 16 +++++++++++++++- engine/settings/all_in_one.py | 7 ++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/engine/apps/oss_installation/tasks.py b/engine/apps/oss_installation/tasks.py index 2c11a54a..2bb54991 100644 --- a/engine/apps/oss_installation/tasks.py +++ b/engine/apps/oss_installation/tasks.py @@ -7,7 +7,7 @@ from django.utils import timezone from rest_framework import status from apps.base.utils import live_settings -from apps.oss_installation.models import CloudHeartbeat, OssInstallation +from apps.oss_installation.models import CloudConnector, CloudHeartbeat, OssInstallation from apps.oss_installation.usage_stats import UsageStatsService from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -93,3 +93,17 @@ def send_cloud_heartbeat(): if cloud_heartbeat.pk is not None: cloud_heartbeat.save() logger.info("Finish send cloud heartbeat") + + +@shared_dedicated_queue_retry_task() +def sync_users_with_cloud(): + logger.info("Start sync_users_with_cloud") + connector = CloudConnector.objects.first() + if connector is not None: + status, error = connector.sync_users_with_cloud() + log_message = "Users synced. Status {status}." + if error: + log_message += f" Error {error}" + logger.info(log_message) + else: + logger.info("Grafana Cloud is not connected") diff --git a/engine/settings/all_in_one.py b/engine/settings/all_in_one.py index e2196274..221edd52 100644 --- a/engine/settings/all_in_one.py +++ b/engine/settings/all_in_one.py @@ -40,6 +40,7 @@ if TESTING: # TODO: OSS: Add these setting to oss settings file. Add Version there too. OSS_INSTALLATION_FEATURES_ENABLED = True +SEND_ANONYMOUS_USAGE_STATS = True INSTALLED_APPS += ["apps.oss_installation"] # noqa @@ -55,4 +56,8 @@ CELERY_BEAT_SCHEDULE["send_cloud_heartbeat"] = { # noqa "args": (), } # noqa -SEND_ANONYMOUS_USAGE_STATS = True +CELERY_BEAT_SCHEDULE["sync_users_with_cloud"] = { # noqa + "task": "apps.oss_installation.tasks.sync_users_with_cloud", + "schedule": crontab(hour="*/12"), # noqa + "args": (), +} # noqa From 893da302e146da12512c2b51d0db2bdf25f79046 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 15:04:23 +0400 Subject: [PATCH 028/132] Fix cloud_users view --- .../oss_installation/serializers/cloud_user.py | 2 +- engine/apps/oss_installation/urls.py | 17 ++++++----------- .../apps/oss_installation/views/cloud_users.py | 10 +++++++--- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/engine/apps/oss_installation/serializers/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py index d8e35791..52f2d0e0 100644 --- a/engine/apps/oss_installation/serializers/cloud_user.py +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -12,7 +12,7 @@ class CloudUserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ["sync_data"] + fields = ["cloud_data"] def get_cloud_data(self, obj): link = None diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 25708249..9ff5efc2 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -1,19 +1,14 @@ -from django.urls import path +from django.urls import include, path -from common.api_helpers.optional_slash_router import optional_slash_path +from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path from .views import CloudConnectionView, CloudUsersView, CloudUserView +router = OptionalSlashRouter() +router.register("cloud_users", CloudUserView, basename="cloud-users") + urlpatterns = [ + path("", include(router.urls)), optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud-users-list"), - path( - "cloud_users/", - CloudUserView.as_view( - { - "get": "retrieve", - } - ), - name="cloud-user-detail", - ), optional_slash_path("cloud_connection", CloudConnectionView.as_view(), name="cloud-connection-status"), ] diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index ab28c677..5f6cc67f 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -7,7 +7,7 @@ from rest_framework.response import Response from rest_framework.views import APIView import apps.oss_installation.constants as cloud_constants -from apps.api.permissions import ActionPermission, IsAdmin, IsOwnerOrAdmin +from apps.api.permissions import ActionPermission, AnyRole, IsAdmin, IsOwnerOrAdmin from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.models import CloudConnector, CloudUserIdentity from apps.oss_installation.serializers import CloudUserSerializer @@ -81,8 +81,12 @@ class CloudUserView( authentication_classes = (PluginAuthentication,) permission_classes = (IsAuthenticated, ActionPermission) + action_permissions = { + AnyRole: ("retrieve",), + IsAdmin: ("sync",), + } action_object_permissions = { - IsOwnerOrAdmin: ("retrieve",), + IsOwnerOrAdmin: ("retrieve", "sync"), } serializer_class = CloudUserSerializer @@ -91,7 +95,7 @@ class CloudUserView( return queryset @action(detail=True, methods=["post"]) - def sync_with_cloud(self, request, pk): + def sync(self, request, pk): user = self.get_object() connector = CloudConnector.objects.first() if connector is not None: From 1f49265079353199a4060ad31c63cb476b2aa6f8 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 15:38:08 +0400 Subject: [PATCH 029/132] Fix sync_user_with_cloud --- engine/apps/oss_installation/models/cloud_connector.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/engine/apps/oss_installation/models/cloud_connector.py b/engine/apps/oss_installation/models/cloud_connector.py index 39edc18c..589de0d7 100644 --- a/engine/apps/oss_installation/models/cloud_connector.py +++ b/engine/apps/oss_installation/models/cloud_connector.py @@ -7,6 +7,7 @@ from django.db import models, transaction from apps.base.utils import live_settings from apps.oss_installation.models.cloud_user_identity import CloudUserIdentity from apps.user_management.models import User +from common.constants.role import Role from settings.base import GRAFANA_CLOUD_ONCALL_API_URL logger = logging.getLogger(__name__) @@ -62,7 +63,7 @@ class CloudConnector(models.Model): logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is not set") error_msg = "GRAFANA_CLOUD_ONCALL_TOKEN is not set" - existing_emails = list(User.objects.values_list("email", flat=True)) + existing_emails = list(User.objects.filter(role__in=(Role.ADMIN, Role.EDITOR)).values_list("email", flat=True)) matching_users = [] users_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/users") @@ -130,7 +131,7 @@ class CloudConnector(models.Model): if len(data["results"]) != 0: cloud_used_data = data["results"][0] with transaction.atomic(): - CloudUserIdentity.objects.filter(email=user.emai).delete() + CloudUserIdentity.objects.filter(email=user.email).delete() CloudUserIdentity.objects.create( email=user.email, phone_number_verified=cloud_used_data["is_phone_number_verified"], From 82ec0c9324017d0279d82f5c5fba1f6bfc86df83 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Wed, 8 Jun 2022 14:03:52 +0200 Subject: [PATCH 030/132] Cloud notifications enpoints --- .../containers/UserSettings/parts/index.tsx | 9 +- .../CloudPhoneSettings/CloudPhoneSettings.tsx | 142 ++++--- grafana-plugin/src/models/cloud/cloud.ts | 7 +- .../models/global_setting/global_setting.ts | 5 + .../src/pages/cloud/CloudPage.module.css | 16 + grafana-plugin/src/pages/cloud/CloudPage.tsx | 359 +++++++++++------- grafana-plugin/src/plugin.json | 7 + grafana-plugin/src/state/features.ts | 1 + 8 files changed, 359 insertions(+), 187 deletions(-) diff --git a/grafana-plugin/src/containers/UserSettings/parts/index.tsx b/grafana-plugin/src/containers/UserSettings/parts/index.tsx index 7f966bfc..62f14daf 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react'; import { Tab, TabContent, TabsBar } from '@grafana/ui'; import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; import Block from 'components/GBlock/Block'; import MobileAppVerification from 'containers/MobileAppVerification/MobileAppVerification'; @@ -13,6 +14,7 @@ import PhoneVerification from 'containers/UserSettings/parts/tabs/PhoneVerificat import TelegramInfo from 'containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo'; import { UserInfoTab } from 'containers/UserSettings/parts/tabs/UserInfoTab/UserInfoTab'; import { User } from 'models/user/user.types'; +import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import styles from 'containers/UserSettings/parts/index.module.css'; @@ -101,12 +103,11 @@ interface TabsContentProps { isDesktopOrLaptop: boolean; } -export const TabsContent = (props: TabsContentProps) => { +export const TabsContent = observer((props: TabsContentProps) => { const { id, activeTab, onTabChange, isDesktopOrLaptop } = props; const store = useStore(); const { userStore } = store; - const [isPhoneEnabled, setIsPhoneEnabled] = useState(false); const storeUser = userStore.items[id]; @@ -127,7 +128,7 @@ export const TabsContent = (props: TabsContentProps) => { ))} {activeTab === UserSettingsTab.NotificationSettings && } {activeTab === UserSettingsTab.PhoneVerification && - (isPhoneEnabled ? ( + (store.hasFeature(AppFeature.CloudNotifications) ? ( ) : ( @@ -139,4 +140,4 @@ export const TabsContent = (props: TabsContentProps) => { {activeTab === UserSettingsTab.TelegramInfo && } ); -}; +}); diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index dd833057..a5ea00f1 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -1,8 +1,20 @@ import React, { useCallback, useEffect, useState } from 'react'; import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; -import { Field, Input, Button, Modal, HorizontalGroup, Alert, Icon, VerticalGroup, Table } from '@grafana/ui'; +import { + Field, + Input, + Button, + Modal, + HorizontalGroup, + Alert, + Icon, + VerticalGroup, + Table, + LoadingPlaceholder, +} from '@grafana/ui'; import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; import Block from 'components/GBlock/Block'; import GTable from 'components/GTable/GTable'; @@ -20,66 +32,106 @@ const cx = cn.bind(styles); interface CloudPhoneSettingsProps extends WithStoreProps {} -const CloudPhoneSettings = (props: CloudPhoneSettingsProps) => { +const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { const store = useStore(); const [isAccountMatched, setIsAccountMatched] = useState(true); const [isPhoneVerified, setIsPhoneVerified] = useState(true); + const [userStatus, setUserStatus] = useState(0); + const [userLink, setUserLink] = useState(null); - const signUpGrafanaCloud = () => { - console.log('Sign UP'); + useEffect(() => { + getCloudUserInfo(); + }, []); + + const handleLinkClick = (link: string) => { + getLocationSrv().update({ partial: false, path: link }); }; - const handleLinkClick = () => { + + const syncUser = () => { store.cloudStore.syncCloudUser(store.userStore.currentUserPk); }; + const getCloudUserInfo = async () => { + await store.cloudStore.updateItems(); + const { count, results } = await store.cloudStore.getSearchResult(); + console.log('RES', results); + const cloudUser = + results && (await results.find((element: { id: string }) => element.id === store.userStore.currentUserPk)); + console.log('CLOUD USER', cloudUser); + setUserStatus(cloudUser?.cloud_data?.status); + setUserLink(cloudUser?.cloud_data?.link); + }; + + const UserCloudStatus = () => { + switch (userStatus) { + case 0: + return ( + + Grafana Cloud is not synced + + ); + case 1: + return ( + + + { + 'We can’t find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). ' + } + + + + ); + case 2: + return ( + + + Your account successfully matched with the Grafana Cloud account. Please verify your phone number.{' '} + + + + ); + case 3: + return ( + + + Your account successfully matched with the Grafana Cloud account. Your phone number is verified.{' '} + + + + ); + default: + return ( + + + { + 'We can’t find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). ' + } + + + + ); + } + }; + return ( OnCall use Grafana Cloud for SMS and phone call notifications - - {isAccountMatched ? ( - isPhoneVerified ? ( - - - Your account successfully matched with the Grafana Cloud account. Please verify your phone number.{' '} - - - - ) : ( - - - Your account successfully matched with the Grafana Cloud account. Your phone number is verified. - - - - ) - ) : ( - - - {'We can’t find a matching account in the connected Grafana Cloud instance (matching happens by e-mail). '} - - - - )} + {userStatus ? : } ); -}; +}); export default withMobXProviderContext(CloudPhoneSettings); diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts index f41512dd..f1dd54f2 100644 --- a/grafana-plugin/src/models/cloud/cloud.ts +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -59,18 +59,15 @@ export class CloudStore extends BaseStore { } async syncCloudUser(id: string) { - return await makeRequest(`${this.path}${id}/sync_with_cloud/`, { method: 'POST' }); + return await makeRequest(`${this.path}${id}/sync/`, { method: 'POST' }); } async getCloudConnectionStatus() { return await makeRequest(`/cloud_connection/`, { method: 'GET' }); } - @action - async connectToCloud(token: string) {} - @action async disconnectToCloud() { - return await makeRequest(`/live_settings/`, { method: 'DELETE' }); + return await makeRequest(`/cloud_connection/`, { method: 'DELETE' }); } } diff --git a/grafana-plugin/src/models/global_setting/global_setting.ts b/grafana-plugin/src/models/global_setting/global_setting.ts index a7e6deb0..edcb2986 100644 --- a/grafana-plugin/src/models/global_setting/global_setting.ts +++ b/grafana-plugin/src/models/global_setting/global_setting.ts @@ -60,4 +60,9 @@ export class GlobalSettingStore extends BaseStore { return this.searchResult[query].map((globalSettingId: GlobalSetting['id']) => this.items[globalSettingId]); } + + async getGlobalSettingItemByName(name: string) { + const results = await this.getAll(); + return results.find((element: { name: string }) => element.name === name); + } } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.module.css b/grafana-plugin/src/pages/cloud/CloudPage.module.css index ba98f153..14f11ba5 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.module.css +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -37,6 +37,22 @@ color: var(--secondary-text-color); } +.error-icon { + display: inline-block; + white-space: break-spaces; + line-height: 20px; + color: var(--error-text-color); +} + +.error-icon svg { + vertical-align: middle; +} + +.heart-icon { + color: var(--secondary-text-color); + margin-right: 8px; +} + .block-button { margin-top: 24px; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index 5aa1b8fa..5c185597 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -1,8 +1,20 @@ import React, { useCallback, useEffect, useState } from 'react'; import { getLocationSrv, LocationUpdate } from '@grafana/runtime'; -import { Field, Input, Button, Modal, HorizontalGroup, Alert, Icon, VerticalGroup, Table } from '@grafana/ui'; +import { + Field, + Input, + Button, + Modal, + HorizontalGroup, + Alert, + Icon, + VerticalGroup, + Table, + LoadingPlaceholder, +} from '@grafana/ui'; import cn from 'classnames/bind'; +import { observer } from 'mobx-react'; import Block from 'components/GBlock/Block'; import GTable from 'components/GTable/GTable'; @@ -14,36 +26,45 @@ import { Cloud } from 'models/cloud/cloud.types'; import { WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; import { withMobXProviderContext } from 'state/withStore'; +import { openErrorNotification } from 'utils'; import styles from './CloudPage.module.css'; const cx = cn.bind(styles); interface CloudPageProps extends WithStoreProps {} +const ITEMS_PER_PAGE = 1; -const CloudPage = (props: CloudPageProps) => { +const CloudPage = observer((props: CloudPageProps) => { const store = useStore(); + const [page, setPage] = useState(1); const [cloudApiKey, setCloudApiKey] = useState(''); - const [cloudIsConnected, setCloudIsConnected] = useState(true); + const [apiKeyError, setApiKeyError] = useState(false); + const [cloudIsConnected, setCloudIsConnected] = useState(undefined); + const [heartbitLink, setHeartbitLink] = useState(null); + const [heartbitStatus, setHeartbitStatus] = useState(false); const [showConfirmationModal, setShowConfirmationModal] = useState(false); + const [syncingUsers, setSyncingUsers] = useState(false); useEffect(() => { - store.cloudStore.updateItems(); + store.cloudStore.updateItems(page); store.cloudStore.getCloudConnectionStatus().then((cloudStatus) => { setCloudIsConnected(cloudStatus.cloud_connection_status); + setHeartbitStatus(cloudStatus.cloud_heartbeat_enabled); + setHeartbitLink(cloudStatus.cloud_heartbeat_link); }); }, []); - const data = [ - { id: 'yshanyrova', email: 'y.shanyrova@grafana.com', cloud_data: { status: 2, link: '/test/abc' } }, - { id: 'amixradmin', email: 'amixr-admin@grafana.com', cloud_data: { status: 1, link: '/test/abc' } }, - { id: 'amixr', email: 'amixr@grafana.com', cloud_data: { status: undefined, link: '/test/abc' } }, - ]; + const { count, results } = store.cloudStore.getSearchResult(); - // const { count, results } = store.cloudStore.getSearchResult(); + const handleChangePage = (page: number) => { + setPage(page); + store.cloudStore.updateItems(page); + }; const handleChangeCloudApiKey = useCallback((e) => { setCloudApiKey(e.target.value); + setApiKeyError(false); }, []); const saveKeyAndConnect = () => { @@ -51,20 +72,32 @@ const CloudPage = (props: CloudPageProps) => { }; const disconnectCloudOncall = () => { - console.log('disconnected'); setCloudIsConnected(false); store.cloudStore.disconnectToCloud(); }; - const connectToCloud = () => { - setCloudIsConnected(true); + const connectToCloud = async () => { setShowConfirmationModal(false); - // store.cloudStore.update('') - store.cloudStore.connectToCloud(cloudApiKey); + const globalSettingItem = await store.globalSettingStore.getGlobalSettingItemByName('GRAFANA_CLOUD_ONCALL_TOKEN'); + store.globalSettingStore + .update(globalSettingItem?.id, { name: 'GRAFANA_CLOUD_ONCALL_TOKEN', value: cloudApiKey }) + .then((response) => { + if (response.error) { + setCloudIsConnected(false); + setApiKeyError(true); + openErrorNotification(response.error); + } else { + setCloudIsConnected(true); + syncUsers(); + } + }); }; - const syncUsers = () => { - store.cloudStore.syncCloudUsers(); + const syncUsers = async () => { + setSyncingUsers(true); + await store.cloudStore.syncCloudUsers(); + await store.cloudStore.updateItems(); + setSyncingUsers(false); }; const handleLinkClick = (link: string) => { @@ -76,17 +109,7 @@ const CloudPage = (props: CloudPageProps) => { case 0: return null; case 1: - return ( - - ); + return null; case 2: return ( ); + case 3: + return ( + + ); default: return null; } @@ -107,12 +142,14 @@ const CloudPage = (props: CloudPageProps) => { const renderStatus = (user: Cloud) => { switch (user?.cloud_data?.status) { case 0: - return User not found in the Grafana Cloud; + return Grafana Cloud is not synced; case 1: - return Phone number verified; - + return User not found in Grafana Cloud; case 2: return Phone number is not verified in Grafana Cloud; + case 3: + return Phone number verified; + default: return User not found in Grafana Cloud; } @@ -122,20 +159,26 @@ const CloudPage = (props: CloudPageProps) => { switch (user?.cloud_data?.status) { case 0: return ( - +
- +
); case 1: - return ; + return ( +
+ +
+ ); case 2: return ; + case 3: + return ; default: return ( - +
- +
); } }; @@ -168,40 +211,158 @@ const CloudPage = (props: CloudPageProps) => { }, ]; + const ConnectedBlock = ( + + + + + Cloud OnCall API key + + Cloud OnCall is sucessfully connected. + + + + + + + + + + + + + Monitor cloud instance with heartbeat + + + Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no + heartbeat will be received in 10 minutes, cloud instance will issue an alert. + + {heartbitStatus && heartbitLink && ( + + )} + + + + + + SMS and phone call notifications + + +
+ + { + 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings!' + } + + + ( +
+ + + {count ? count : 0} + {` users matched between OSS and Cloud OnCall`} + + {syncingUsers ? ( + + ) : ( + + )} + +
+ )} + rowKey="id" + // @ts-ignore + columns={columns} + data={results} + pagination={{ + page, + total: Math.ceil((count || 0) / ITEMS_PER_PAGE), + onChange: handleChangePage, + }} + /> +
+
+
+
+ ); + + const DisconnectedBlock = ( + + + + + Cloud OnCall API key + + + + + + + + + + + + + {' '} + Monitor cloud instance with heartbeat + + + Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no + heartbeat will be received in 10 minutes, cloud instance will issue an alert. + + + + + + + SMS and phone call notifications + + + Users matched between OSS and Cloud OnCall currently unavialable. + + + + ); + return (
Connect Open Source OnCall and Cloud OnCall - - {cloudIsConnected ? ( - - - Cloud OnCall API key - - Cloud OnCall is sucessfully connected. - - - - - - ) : ( - - - Cloud OnCall API key - - - - - - - )} - + {cloudIsConnected === undefined ? ( + + ) : cloudIsConnected ? ( + ConnectedBlock + ) : ( + DisconnectedBlock + )} {showConfirmationModal && ( { )} - - - - - - - {' '} - Monitor cloud instance with heartbeat - - - Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no - heartbeat will be received in 10 minutes, cloud instance will issue an alert. - - {cloudIsConnected && ( - - )} - - - - - - - SMS and phone call notifications - - {cloudIsConnected ? ( -
- - { - 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings!' - } - - - ( -
- - - {/* {count ? count : 0} */} - {`3 users matched between OSS and Cloud OnCall`} - - - -
- )} - rowKey="id" - // @ts-ignore - columns={columns} - data={data} - /> -
- ) : ( - Users matched between OSS and Cloud OnCall currently unavialable. - )} -
-
); -}; +}); export default withMobXProviderContext(CloudPage); diff --git a/grafana-plugin/src/plugin.json b/grafana-plugin/src/plugin.json index 4f5132f1..38a4d7bc 100644 --- a/grafana-plugin/src/plugin.json +++ b/grafana-plugin/src/plugin.json @@ -98,6 +98,13 @@ "path": "/a/grafana-oncall-app/?page=outgoing_webhooks", "role": "Viewer", "addToNav": true + }, + { + "type": "page", + "name": "Cloud", + "path": "/a/grafana-oncall-app/?page=cloud", + "role": "Editor", + "addToNav": true } ], "routes": [ diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts index 8363575c..91a92d8c 100644 --- a/grafana-plugin/src/state/features.ts +++ b/grafana-plugin/src/state/features.ts @@ -3,4 +3,5 @@ export enum AppFeature { Telegram = 'telegram', LiveSettings = 'live_settings', MobileApp = 'mobile_app', + CloudNotifications = 'grafana_cloud_notifications', } From ef92ec8aea43786e0a2654bbad9dd935c1ce5288 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Wed, 8 Jun 2022 14:28:59 +0200 Subject: [PATCH 031/132] pagination fix --- grafana-plugin/src/pages/cloud/CloudPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index 5c185597..804c7863 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -33,7 +33,7 @@ import styles from './CloudPage.module.css'; const cx = cn.bind(styles); interface CloudPageProps extends WithStoreProps {} -const ITEMS_PER_PAGE = 1; +const ITEMS_PER_PAGE = 50; const CloudPage = observer((props: CloudPageProps) => { const store = useStore(); From 6c6848d66ddeeac08de2862aec536ae25cbc2000 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Wed, 8 Jun 2022 15:33:50 +0300 Subject: [PATCH 032/132] Logo --- grafana-plugin/src/img/logo.svg | 4 ++-- grafana-plugin/src/utils/consts.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/grafana-plugin/src/img/logo.svg b/grafana-plugin/src/img/logo.svg index 7029f330..7c0277d9 100644 --- a/grafana-plugin/src/img/logo.svg +++ b/grafana-plugin/src/img/logo.svg @@ -1,7 +1,7 @@ - + - + diff --git a/grafana-plugin/src/utils/consts.ts b/grafana-plugin/src/utils/consts.ts index 7546075e..c5e77b2a 100644 --- a/grafana-plugin/src/utils/consts.ts +++ b/grafana-plugin/src/utils/consts.ts @@ -1,4 +1,4 @@ import plugin from '../../package.json'; // eslint-disable-line export const APP_TITLE = 'Grafana OnCall'; -export const APP_SUBTITLE = `Incident Response powered by Amixr (${plugin?.version})`; +export const APP_SUBTITLE = `Incident Response (${plugin?.version})`; From 5a8bd8493f4735d7b5702cfdd2e0862ae8b627c9 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Wed, 8 Jun 2022 15:07:49 +0200 Subject: [PATCH 033/132] endpoint for cloud user --- .../parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx | 7 +------ grafana-plugin/src/models/cloud/cloud.ts | 6 +++++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index a5ea00f1..07544bea 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -52,12 +52,7 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { }; const getCloudUserInfo = async () => { - await store.cloudStore.updateItems(); - const { count, results } = await store.cloudStore.getSearchResult(); - console.log('RES', results); - const cloudUser = - results && (await results.find((element: { id: string }) => element.id === store.userStore.currentUserPk)); - console.log('CLOUD USER', cloudUser); + const cloudUser = await store.cloudStore.getCloudUser(store.userStore.currentUserPk); setUserStatus(cloudUser?.cloud_data?.status); setUserLink(cloudUser?.cloud_data?.link); }; diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts index f1dd54f2..f93c1d46 100644 --- a/grafana-plugin/src/models/cloud/cloud.ts +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -59,7 +59,11 @@ export class CloudStore extends BaseStore { } async syncCloudUser(id: string) { - return await makeRequest(`${this.path}${id}/sync/`, { method: 'POST' }); + return await makeRequest(`${this.path}`, { method: 'POST' }); + } + + async getCloudUser(id: string) { + return await makeRequest(`${this.path}${id}`, { method: 'GET' }); } async getCloudConnectionStatus() { From c82e06a1e08b97898e89f01e16f706e7735c8cfe Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 17:12:29 +0400 Subject: [PATCH 034/132] Add "grafana_cloud_connection" feature --- engine/apps/api/views/features.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index 79ed373b..11b861ff 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -11,6 +11,7 @@ FEATURE_TELEGRAM = "telegram" FEATURE_LIVE_SETTINGS = "live_settings" MOBILE_APP_PUSH_NOTIFICATIONS = "mobile_app" FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications" +FEATURE_GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection" class FeaturesAPIView(APIView): @@ -33,12 +34,6 @@ class FeaturesAPIView(APIView): if settings.FEATURE_TELEGRAM_INTEGRATION_ENABLED: enabled_features.append(FEATURE_TELEGRAM) - if settings.FEATURE_LIVE_SETTINGS_ENABLED: - enabled_features.append(FEATURE_LIVE_SETTINGS) - - if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: - enabled_features.append(FEATURE_GRAFANA_CLOUD_NOTIFICATIONS) - if settings.MOBILE_APP_PUSH_NOTIFICATIONS_ENABLED: DynamicSetting = apps.get_model("base", "DynamicSetting") mobile_app_settings = DynamicSetting.objects.get_or_create( @@ -53,4 +48,11 @@ class FeaturesAPIView(APIView): if request.auth.organization.pk in mobile_app_settings.json_value["org_ids"]: enabled_features.append(MOBILE_APP_PUSH_NOTIFICATIONS) + if settings.OSS_INSTALLATION_FEATURES_ENABLED: + enabled_features.append(FEATURE_GRAFANA_CLOUD_CONNECTION) + if settings.FEATURE_LIVE_SETTINGS_ENABLED: + enabled_features.append(FEATURE_LIVE_SETTINGS) + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + enabled_features.append(FEATURE_GRAFANA_CLOUD_NOTIFICATIONS) + return enabled_features From c1f9899e5f216576d3425ba46acd30699a844b60 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 17:31:12 +0400 Subject: [PATCH 035/132] Clean up settings --- engine/settings/base.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/engine/settings/base.py b/engine/settings/base.py index 281a8a86..507ae112 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -6,7 +6,8 @@ from celery.schedules import crontab from common.utils import getenv_boolean VERSION = "dev-oss" -SEND_ANONYMOUS_USAGE_STATS = False +OSS = getenv_boolean("OSS", True) +SEND_ANONYMOUS_USAGE_STATS = getenv_boolean("SEND_ANONYMOUS_USAGE_STATS", default=True) # License is OpenSource or Cloud OPEN_SOURCE_LICENSE_NAME = "OpenSource" @@ -49,7 +50,8 @@ FEATURE_LIVE_SETTINGS_ENABLED = getenv_boolean("FEATURE_LIVE_SETTINGS_ENABLED", FEATURE_TELEGRAM_INTEGRATION_ENABLED = getenv_boolean("FEATURE_TELEGRAM_INTEGRATION_ENABLED", default=False) FEATURE_EMAIL_INTEGRATION_ENABLED = getenv_boolean("FEATURE_EMAIL_INTEGRATION_ENABLED", default=False) FEATURE_SLACK_INTEGRATION_ENABLED = getenv_boolean("FEATURE_SLACK_INTEGRATION_ENABLED", default=False) -OSS_INSTALLATION_FEATURES_ENABLED = False +GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) +GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID") TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN") @@ -70,6 +72,10 @@ SENDGRID_FROM_EMAIL = os.environ.get("SENDGRID_FROM_EMAIL") SENDGRID_SECRET_KEY = os.environ.get("SENDGRID_SECRET_KEY") SENDGRID_INBOUND_EMAIL_DOMAIN = os.environ.get("SENDGRID_INBOUND_EMAIL_DOMAIN") +# For Grafana Cloud integration +GRAFANA_CLOUD_ONCALL_API_URL = os.environ.get("GRAFANA_CLOUD_ONCALL_API_URL", "https://a-prod-us-central-0.grafana.net") +GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None) + # Application definition INSTALLED_APPS = [ @@ -409,11 +415,6 @@ SELF_HOSTED_SETTINGS = { "ORG_TITLE": "Self-Hosted Organization", } -GRAFANA_CLOUD_ONCALL_API_URL = "https://a-02-dev-us-central-0.grafana.net/" -GRAFANA_CLOUD_ONCALL_TOKEN = os.environ.get("GRAFANA_CLOUD_ONCALL_TOKEN", None) -GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) -GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) - GRAFANA_INCIDENT_STATIC_API_KEY = os.environ.get("GRAFANA_INCIDENT_STATIC_API_KEY", None) DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 From e277534f32383967521b534cd3491ba64f0e79d4 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 17:31:55 +0400 Subject: [PATCH 036/132] Sync only admins and editors --- engine/apps/api/views/features.py | 2 +- engine/apps/oss_installation/views/cloud_users.py | 3 ++- .../public_api/throttlers/phone_notification_throttler.py | 6 ++++++ engine/apps/public_api/views/phone_notifications.py | 5 ++++- engine/apps/twilioapp/models/phone_call.py | 2 ++ engine/apps/twilioapp/models/sms_message.py | 2 ++ engine/engine/urls.py | 2 +- 7 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 engine/apps/public_api/throttlers/phone_notification_throttler.py diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index 11b861ff..81d0825a 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -48,7 +48,7 @@ class FeaturesAPIView(APIView): if request.auth.organization.pk in mobile_app_settings.json_value["org_ids"]: enabled_features.append(MOBILE_APP_PUSH_NOTIFICATIONS) - if settings.OSS_INSTALLATION_FEATURES_ENABLED: + if settings.OSS: enabled_features.append(FEATURE_GRAFANA_CLOUD_CONNECTION) if settings.FEATURE_LIVE_SETTINGS_ENABLED: enabled_features.append(FEATURE_LIVE_SETTINGS) diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index 5f6cc67f..2f740b64 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -14,6 +14,7 @@ from apps.oss_installation.serializers import CloudUserSerializer from apps.user_management.models import User from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.api_helpers.paginators import HundredPageSizePaginator +from common.constants.role import Role class CloudUsersView(HundredPageSizePaginator, APIView): @@ -23,7 +24,7 @@ class CloudUsersView(HundredPageSizePaginator, APIView): def get(self, request): organization = request.user.organization - queryset = User.objects.filter(organization=organization) + queryset = User.objects.filter(organization=organization, role__in=[Role.ADMIN, Role.EDITOR]) if request.user.current_team is not None: queryset = queryset.filter(teams=request.user.current_team).distinct() diff --git a/engine/apps/public_api/throttlers/phone_notification_throttler.py b/engine/apps/public_api/throttlers/phone_notification_throttler.py new file mode 100644 index 00000000..a66e19a1 --- /dev/null +++ b/engine/apps/public_api/throttlers/phone_notification_throttler.py @@ -0,0 +1,6 @@ +from rest_framework.throttling import UserRateThrottle + + +class PhoneNotificationThrottler(UserRateThrottle): + scope = "phone_notification" + rate = "60/m" diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py index 5269d4a9..b53e7b1d 100644 --- a/engine/apps/public_api/views/phone_notifications.py +++ b/engine/apps/public_api/views/phone_notifications.py @@ -5,6 +5,7 @@ from rest_framework.views import APIView from twilio.base.exceptions import TwilioRestException from apps.auth_token.auth import ApiTokenAuthentication +from apps.public_api.throttlers.phone_notification_throttler import PhoneNotificationThrottler from apps.twilioapp.models import PhoneCall, SMSMessage @@ -17,7 +18,9 @@ class MakeCallView(APIView): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) - # TODO: add ratelimit + throttle_classes = [ + PhoneNotificationThrottler, + ] def post(self, request): serializer = PhoneNotificationDataSerializer(data=request.data) diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py index 72389811..ad594237 100644 --- a/engine/apps/twilioapp/models/phone_call.py +++ b/engine/apps/twilioapp/models/phone_call.py @@ -13,6 +13,7 @@ from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertG from apps.alerts.signals import user_notification_action_triggered_signal from apps.twilioapp.constants import TwilioCallStatuses from apps.twilioapp.twilio_client import twilio_client +from common.utils import clean_markup, escape_for_twilio_phone_call logger = logging.getLogger(__name__) @@ -223,6 +224,7 @@ class PhoneCall(models.Model): @classmethod def make_grafana_cloud_call(cls, user, message_body): + message_body = escape_for_twilio_phone_call(clean_markup(message_body)) cls._make_call(user, message_body, grafana_cloud=True) @classmethod diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py index c18dd7e8..393ad0b4 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -12,6 +12,7 @@ from apps.alerts.incident_appearance.renderers.sms_renderer import AlertGroupSms from apps.alerts.signals import user_notification_action_triggered_signal from apps.twilioapp.constants import TwilioMessageStatuses from apps.twilioapp.twilio_client import twilio_client +from common.utils import clean_markup logger = logging.getLogger(__name__) @@ -189,6 +190,7 @@ class SMSMessage(models.Model): @classmethod def send_grafana_cloud_sms(cls, user, message_body): + message_body = clean_markup(message_body) cls._send_sms(user, message_body, grafana_cloud=True) @classmethod diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 702e2907..d2eda753 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -54,7 +54,7 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED: path("slack/", include("apps.slack.urls")), ] -if settings.OSS_INSTALLATION_FEATURES_ENABLED or True: +if settings.OSS: urlpatterns += [ path("api/internal/v1/", include("apps.oss_installation.urls")), ] From 5be51de1eae16cc4f2ae6d234c5ea2fb14812f94 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 18:14:50 +0400 Subject: [PATCH 037/132] Sync only admins and editors --- engine/apps/api/views/features.py | 1 + 1 file changed, 1 insertion(+) diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index 81d0825a..4f106a89 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -49,6 +49,7 @@ class FeaturesAPIView(APIView): enabled_features.append(MOBILE_APP_PUSH_NOTIFICATIONS) if settings.OSS: + # Features below should be enabled only in OSS enabled_features.append(FEATURE_GRAFANA_CLOUD_CONNECTION) if settings.FEATURE_LIVE_SETTINGS_ENABLED: enabled_features.append(FEATURE_LIVE_SETTINGS) From af06d6491b7b0c34da9d46463c38605d03d8aae0 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Wed, 8 Jun 2022 18:25:58 +0400 Subject: [PATCH 038/132] Add info throttler --- engine/apps/public_api/throttlers/__init__.py | 3 +++ engine/apps/public_api/throttlers/info_throttler.py | 6 ++++++ engine/apps/public_api/views/info.py | 4 ++-- 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 engine/apps/public_api/throttlers/info_throttler.py diff --git a/engine/apps/public_api/throttlers/__init__.py b/engine/apps/public_api/throttlers/__init__.py index e69de29b..20dc00d7 100644 --- a/engine/apps/public_api/throttlers/__init__.py +++ b/engine/apps/public_api/throttlers/__init__.py @@ -0,0 +1,3 @@ +from .info_throttler import InfoThrottler # noqa: F401 +from .phone_notification_throttler import PhoneNotificationThrottler # noqa: F401 +from .user_throttle import UserThrottle # noqa: F401 diff --git a/engine/apps/public_api/throttlers/info_throttler.py b/engine/apps/public_api/throttlers/info_throttler.py new file mode 100644 index 00000000..a48bce22 --- /dev/null +++ b/engine/apps/public_api/throttlers/info_throttler.py @@ -0,0 +1,6 @@ +from rest_framework.throttling import UserRateThrottle + + +class InfoThrottler(UserRateThrottle): + scope = "info" + rate = "100/m" diff --git a/engine/apps/public_api/views/info.py b/engine/apps/public_api/views/info.py index f9649181..f9cc13ca 100644 --- a/engine/apps/public_api/views/info.py +++ b/engine/apps/public_api/views/info.py @@ -3,14 +3,14 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api.throttlers.user_throttle import UserThrottle +from apps.public_api.throttlers import InfoThrottler class InfoView(APIView): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) - throttle_classes = [UserThrottle] + throttle_classes = [InfoThrottler] def get(self, request): response = {"url": self.request.auth.organization.grafana_url} From fc53cd014eb8959f8811bb97c25b4ae726bd61aa Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Wed, 8 Jun 2022 16:09:34 +0200 Subject: [PATCH 039/132] Chnages regarding env variables and Viewer role --- grafana-plugin/src/GrafanaPluginRootPage.tsx | 1 + .../containers/UserSettings/parts/index.tsx | 5 +- .../CloudPhoneSettings/CloudPhoneSettings.tsx | 56 ++++++++++++++----- grafana-plugin/src/pages/cloud/CloudPage.tsx | 13 ++++- grafana-plugin/src/pages/index.ts | 1 + grafana-plugin/src/plugin.json | 7 --- grafana-plugin/src/state/features.ts | 1 + grafana-plugin/src/utils/hooks.ts | 8 ++- 8 files changed, 65 insertions(+), 27 deletions(-) diff --git a/grafana-plugin/src/GrafanaPluginRootPage.tsx b/grafana-plugin/src/GrafanaPluginRootPage.tsx index aacc6f44..81c3c573 100644 --- a/grafana-plugin/src/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/GrafanaPluginRootPage.tsx @@ -118,6 +118,7 @@ export const Root = observer((props: AppRootProps) => { meta, grafanaUser: window.grafanaBootData.user, enableLiveSettings: store.hasFeature(AppFeature.LiveSettings), + enableCloudPage: store.hasFeature(AppFeature.CloudConnection), }), [meta, pathWithoutLeadingSlash, page, store.features] ) diff --git a/grafana-plugin/src/containers/UserSettings/parts/index.tsx b/grafana-plugin/src/containers/UserSettings/parts/index.tsx index 62f14daf..7cbb0f4b 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { Tab, TabContent, TabsBar } from '@grafana/ui'; import cn from 'classnames/bind'; @@ -105,6 +105,9 @@ interface TabsContentProps { export const TabsContent = observer((props: TabsContentProps) => { const { id, activeTab, onTabChange, isDesktopOrLaptop } = props; + useEffect(() => { + store.updateFeatures(); + }, []); const store = useStore(); const { userStore } = store; diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index 07544bea..4a9d6f20 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -22,8 +22,10 @@ import PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import WithConfirm from 'components/WithConfirm/WithConfirm'; import { User as UserType } from 'models/user/user.types'; +import { AppFeature } from 'state/features'; import { WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; +import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import styles from './CloudPhoneSettings.module.css'; @@ -34,8 +36,7 @@ interface CloudPhoneSettingsProps extends WithStoreProps {} const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { const store = useStore(); - const [isAccountMatched, setIsAccountMatched] = useState(true); - const [isPhoneVerified, setIsPhoneVerified] = useState(true); + const [syncing, setSyncing] = useState(false); const [userStatus, setUserStatus] = useState(0); const [userLink, setUserLink] = useState(null); @@ -47,8 +48,10 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { getLocationSrv().update({ partial: false, path: link }); }; - const syncUser = () => { - store.cloudStore.syncCloudUser(store.userStore.currentUserPk); + const syncUser = async () => { + setSyncing(true); + await store.cloudStore.syncCloudUser(store.userStore.currentUserPk); + setSyncing(false); }; const getCloudUserInfo = async () => { @@ -60,6 +63,18 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { const UserCloudStatus = () => { switch (userStatus) { case 0: + if (store.hasFeature(AppFeature.CloudNotifications)) { + return ( + + Your account successfully matched, but Cloud is not connected. + + + + + ); + } return ( Grafana Cloud is not synced @@ -117,15 +132,30 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { }; return ( - - - OnCall use Grafana Cloud for SMS and phone call notifications - - - {userStatus ? : } - + <> + {store.isUserActionAllowed(UserAction.UpdateOtherUsersSettings) ? ( + + + OnCall use Grafana Cloud for SMS and phone call notifications + {syncing ? ( + + ) : ( + + )} + + {!syncing ? : } + + ) : ( + + OnCall use Grafana Cloud for SMS and phone call notifications + You do not have permission to perform this action. Ask an admin to upgrade your permissions. + + )} + ); }); diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index 804c7863..c02cf162 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -52,8 +52,9 @@ const CloudPage = observer((props: CloudPageProps) => { setCloudIsConnected(cloudStatus.cloud_connection_status); setHeartbitStatus(cloudStatus.cloud_heartbeat_enabled); setHeartbitLink(cloudStatus.cloud_heartbeat_link); + getApiKeyFromGlobalSettings(); }); - }, []); + }, [cloudIsConnected]); const { count, results } = store.cloudStore.getSearchResult(); @@ -76,6 +77,12 @@ const CloudPage = observer((props: CloudPageProps) => { store.cloudStore.disconnectToCloud(); }; + const getApiKeyFromGlobalSettings = async () => { + const globalSettingItem = await store.globalSettingStore.getGlobalSettingItemByName('GRAFANA_CLOUD_ONCALL_TOKEN'); + if (cloudIsConnected === false) { + setCloudApiKey(globalSettingItem?.value); + } + }; const connectToCloud = async () => { setShowConfirmationModal(false); const globalSettingItem = await store.globalSettingStore.getGlobalSettingItemByName('GRAFANA_CLOUD_ONCALL_TOKEN'); @@ -260,7 +267,7 @@ const CloudPage = observer((props: CloudPageProps) => {
{ - 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings!' + 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings! Only users with Admin or Editor role will be synced.' } @@ -317,7 +324,7 @@ const CloudPage = observer((props: CloudPageProps) => { style={{ width: '100%' }} invalid={apiKeyError} > - + + )} + {isSelfHostedInstall ? ( - -
- ) : ( - - )} + ) : ( + + )}{' '} + ) : ( @@ -285,6 +290,21 @@ export const PluginConfigPage = (props: Props) => {
+ + + Need help? +
+ 1. Talk to the developers in the #grafana-oncall channel at{' '} +
+ Slack + +
+ 2. Search for issues or create a new one in the{' '} + + GitHub + + +

2. Conect the backend and the plugin

{'Plugin <-> backend connection status:'}

@@ -308,10 +328,15 @@ Seek for such a line: “Your invite token: <> , use it in the Graf + It should be rechable from Grafana. Possible options:
+ http://host.docker.internal:8000 (if you run backend in the docker locally) +
+ http://localhost:8000
+ ... + + } >
From 4818fd801c779c6bfc1fd6f4ed4c3dcd1031421c Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Thu, 9 Jun 2022 12:47:22 +0400 Subject: [PATCH 053/132] Small improvements --- engine/apps/oss_installation/cloud_sync.py | 0 .../models/cloud_connector.py | 3 --- .../models/cloud_user_identity.py | 6 ----- .../serializers/cloud_user.py | 2 +- engine/apps/oss_installation/utils.py | 23 ------------------- .../public_api/views/phone_notifications.py | 4 ++++ 6 files changed, 5 insertions(+), 33 deletions(-) delete mode 100644 engine/apps/oss_installation/cloud_sync.py diff --git a/engine/apps/oss_installation/cloud_sync.py b/engine/apps/oss_installation/cloud_sync.py deleted file mode 100644 index e69de29b..00000000 diff --git a/engine/apps/oss_installation/models/cloud_connector.py b/engine/apps/oss_installation/models/cloud_connector.py index 589de0d7..38541bf5 100644 --- a/engine/apps/oss_installation/models/cloud_connector.py +++ b/engine/apps/oss_installation/models/cloud_connector.py @@ -19,9 +19,6 @@ class CloudConnector(models.Model): """ cloud_url = models.URLField() - # organization = models.OneToOneField( - # "user_management.organization", related_name="cloud_connector", on_delete=models.CASCADE - # ) @classmethod def sync_with_cloud(cls, token=None): diff --git a/engine/apps/oss_installation/models/cloud_user_identity.py b/engine/apps/oss_installation/models/cloud_user_identity.py index 1918ddcb..ec83ac2f 100644 --- a/engine/apps/oss_installation/models/cloud_user_identity.py +++ b/engine/apps/oss_installation/models/cloud_user_identity.py @@ -5,9 +5,3 @@ class CloudUserIdentity(models.Model): phone_number_verified = models.BooleanField(default=False) cloud_id = models.CharField(max_length=20) email = models.EmailField() - # organization = models.ForeignKey( - # "user_management.Organization", on_delete=models.CASCADE, related_name="cloud_users" - # ) - # - # class Meta: - # unique_together = ("email", "organization") diff --git a/engine/apps/oss_installation/serializers/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py index 52f2d0e0..228a33c9 100644 --- a/engine/apps/oss_installation/serializers/cloud_user.py +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -24,7 +24,7 @@ class CloudUserSerializer(serializers.ModelSerializer): status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND link = connector.cloud_url elif not cloud_user_identity.phone_number_verified: - status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND + status = cloud_constants.CLOUD_SYNCED_PHONE_NOT_VERIFIED link = urljoin( connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_user_identity.cloud_id}" ) diff --git a/engine/apps/oss_installation/utils.py b/engine/apps/oss_installation/utils.py index c0ca366c..aef70aa4 100644 --- a/engine/apps/oss_installation/utils.py +++ b/engine/apps/oss_installation/utils.py @@ -1,14 +1,11 @@ import logging from contextlib import suppress -from urllib.parse import urljoin -import requests from django.apps import apps from django.utils import timezone from apps.public_api.constants import DEMO_USER_ID from apps.schedules.ical_utils import list_users_to_notify_from_ical_for_period -from settings.base import GRAFANA_CLOUD_ONCALL_API_URL logger = logging.getLogger(__name__) @@ -76,23 +73,3 @@ def active_oss_users_count(): with suppress(KeyError): unique_active_users.remove(demo_user.pk) return len(unique_active_users) - - -def get_cloud_instance_info(api_token): - success = False - error_msg = None - r = None - info_url = urljoin(GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/info/") - try: - r = requests.get(info_url, headers={"AUTHORIZATION": api_token}, timeout=5) - if r.status_code == 200: - success = True - elif r.status_code == 403: - logger.warning("Unable to sync with cloud. GRAFANA_CLOUD_ONCALL_TOKEN is invalid") - error_msg = "Invalid token" - else: - error_msg = f"Non-200 HTTP code. Got {r.status_code}" - except requests.exceptions.RequestException as e: - logger.warning(f"Unable to sync with cloud. Request exception {str(e)}") - error_msg = f"Unable to sync with cloud" - return success, error_msg, r diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py index b53e7b1d..896fe7a1 100644 --- a/engine/apps/public_api/views/phone_notifications.py +++ b/engine/apps/public_api/views/phone_notifications.py @@ -49,6 +49,10 @@ class SendSMSView(APIView): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) + throttle_classes = [ + PhoneNotificationThrottler, + ] + def post(self, request): serializer = PhoneNotificationDataSerializer(data=request.data) serializer.is_valid(raise_exception=True) From 36e71acf438c744a382a3452b11c21a85fbafc52 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Thu, 9 Jun 2022 11:56:27 +0200 Subject: [PATCH 054/132] Removed dublicating information --- .../PluginConfigPage/PluginConfigPage.tsx | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index c105fb05..4295c72e 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -249,22 +249,7 @@ export const PluginConfigPage = (props: Props) => { Configure Grafana OnCall

This page will help you to connect OnCall backend and OnCall Grafana plugin 👋

-

- - - Talk to the OnCall team in the #grafana-oncall channel at{' '} - - Slack - -
- Ask questions at{' '} - - GitHub Discussions - {' '} - or file bugs at{' '} - - GitHub Issues - -
-

+

1. Launch backend

From 94af30b8bbc9744779d172f2b826168ba7220b80 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Thu, 9 Jun 2022 15:11:30 +0400 Subject: [PATCH 055/132] Fixes and logging to debug cloud notifications (#31) --- engine/apps/public_api/views/phone_notifications.py | 5 +++++ engine/apps/twilioapp/models/phone_call.py | 3 ++- engine/apps/twilioapp/models/sms_message.py | 12 ++++++++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py index 896fe7a1..e63b3f14 100644 --- a/engine/apps/public_api/views/phone_notifications.py +++ b/engine/apps/public_api/views/phone_notifications.py @@ -1,3 +1,5 @@ +import logging + from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -8,6 +10,8 @@ from apps.auth_token.auth import ApiTokenAuthentication from apps.public_api.throttlers.phone_notification_throttler import PhoneNotificationThrottler from apps.twilioapp.models import PhoneCall, SMSMessage +logger = logging.getLogger(__name__) + class PhoneNotificationDataSerializer(serializers.Serializer): email = serializers.EmailField() @@ -59,6 +63,7 @@ class SendSMSView(APIView): response_data = {} organization = self.request.auth.organization + logger.info(f"Sending cloud sms. Email {serializer.validated_data['email']}") user = organization.users.filter( email=serializer.validated_data["email"], _verified_phone_number__isnull=False ).first() diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py index ad594237..1b5348ed 100644 --- a/engine/apps/twilioapp/models/phone_call.py +++ b/engine/apps/twilioapp/models/phone_call.py @@ -11,6 +11,7 @@ from twilio.base.exceptions import TwilioRestException from apps.alerts.constants import ActionSource from apps.alerts.incident_appearance.renderers.phone_call_renderer import AlertGroupPhoneCallRenderer from apps.alerts.signals import user_notification_action_triggered_signal +from apps.base.utils import live_settings from apps.twilioapp.constants import TwilioCallStatuses from apps.twilioapp.twilio_client import twilio_client from common.utils import clean_markup, escape_for_twilio_phone_call @@ -158,7 +159,7 @@ class PhoneCall(models.Model): @classmethod def _make_cloud_call(cls, user, message_body): url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/make_call") - auth = {"Authorization": settings.GRAFANA_CLOUD_ONCALL_TOKEN} + auth = {"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN} data = { "email": user.email, "message": message_body, diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py index 393ad0b4..6d70592a 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -10,6 +10,7 @@ from twilio.base.exceptions import TwilioRestException from apps.alerts.incident_appearance.renderers.sms_renderer import AlertGroupSmsRenderer from apps.alerts.signals import user_notification_action_triggered_signal +from apps.base.utils import live_settings from apps.twilioapp.constants import TwilioMessageStatuses from apps.twilioapp.twilio_client import twilio_client from common.utils import clean_markup @@ -123,7 +124,7 @@ class SMSMessage(models.Model): @classmethod def _send_cloud_sms(cls, user, message_body): url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "api/v1/send_sms") - auth = {"Authorization": settings.GRAFANA_CLOUD_ONCALL_TOKEN} + auth = {"Authorization": live_settings.GRAFANA_CLOUD_ONCALL_TOKEN} data = { "email": user.email, "message": message_body, @@ -153,7 +154,8 @@ class SMSMessage(models.Model): cls._send_cloud_sms(user, message_body) else: cls._send_sms(user, message_body, alert_group=alert_group, notification_policy=notification_policy) - except (TwilioRestException, SMSMessage.CloudSendError): + except (TwilioRestException, SMSMessage.CloudSendError) as e: + logger.warning(f"Unable to send sms. Exception {e}") log_record = UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, @@ -163,7 +165,8 @@ class SMSMessage(models.Model): notification_step=notification_policy.step if notification_policy else None, notification_channel=notification_policy.notify_by if notification_policy else None, ) - except SMSMessage.SMSLimitExceeded: + except SMSMessage.SMSLimitExceeded as e: + logger.warning(f"Unable to send sms. Exception {e}") log_record = UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, @@ -173,7 +176,8 @@ class SMSMessage(models.Model): notification_step=notification_policy.step if notification_policy else None, notification_channel=notification_policy.notify_by if notification_policy else None, ) - except SMSMessage.PhoneNumberNotVerifiedError: + except SMSMessage.PhoneNumberNotVerifiedError as e: + logger.warning(f"Unable to send sms. Exception {e}") log_record = UserNotificationPolicyLogRecord( author=user, type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, From 3fae0c9ff2e8f53bbf300792a647b913bbf6d115 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Thu, 9 Jun 2022 15:48:43 +0400 Subject: [PATCH 056/132] Fix always error message for cloud notifications --- engine/apps/public_api/views/phone_notifications.py | 9 +++++++-- engine/apps/twilioapp/models/phone_call.py | 3 ++- engine/apps/twilioapp/models/sms_message.py | 5 +++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/engine/apps/public_api/views/phone_notifications.py b/engine/apps/public_api/views/phone_notifications.py index e63b3f14..f9f96f74 100644 --- a/engine/apps/public_api/views/phone_notifications.py +++ b/engine/apps/public_api/views/phone_notifications.py @@ -32,6 +32,7 @@ class MakeCallView(APIView): response_data = {} organization = self.request.auth.organization + logger.info(f"Making cloud call. Email {serializer.validated_data['email']}") user = organization.users.filter( email=serializer.validated_data["email"], _verified_phone_number__isnull=False ).first() @@ -41,9 +42,11 @@ class MakeCallView(APIView): try: PhoneCall.make_grafana_cloud_call(user, serializer.validated_data["message"]) - except TwilioRestException: + except TwilioRestException as e: + logger.info(f"Making cloud call. Twilio exception {str(e)}") return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data) except PhoneCall.PhoneCallsLimitExceeded: + logger.info(f"Making cloud call. PhoneCallsLimitExceeded") return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"}) return Response(status=status.HTTP_200_OK, data=response_data) @@ -73,9 +76,11 @@ class SendSMSView(APIView): try: SMSMessage.send_grafana_cloud_sms(user, serializer.validated_data["message"]) - except TwilioRestException: + except TwilioRestException as e: + logger.info(f"Sending cloud sms. Twilio exception {str(e)}") return Response(status=status.HTTP_503_SERVICE_UNAVAILABLE, data=response_data) except SMSMessage.SMSLimitExceeded: + logger.info(f"Sending cloud sms. PhoneCallsLimitExceeded") return Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "limit-exceeded"}) return Response(status=status.HTTP_200_OK, data=response_data) diff --git a/engine/apps/twilioapp/models/phone_call.py b/engine/apps/twilioapp/models/phone_call.py index 1b5348ed..64b4304e 100644 --- a/engine/apps/twilioapp/models/phone_call.py +++ b/engine/apps/twilioapp/models/phone_call.py @@ -169,7 +169,8 @@ class PhoneCall(models.Model): except requests.exceptions.RequestException as e: logger.warning(f"Unable to make call through cloud. Request exception {str(e)}") raise PhoneCall.CloudSendError("Unable to make call through cloud: request failed") - + if response.status_code == status.HTTP_200_OK: + logger.info("Make cloud call successfully") if response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded": raise PhoneCall.PhoneCallsLimitExceeded("Organization calls limit exceeded") elif response.status_code == status.HTTP_404_NOT_FOUND: diff --git a/engine/apps/twilioapp/models/sms_message.py b/engine/apps/twilioapp/models/sms_message.py index 6d70592a..00e98e4b 100644 --- a/engine/apps/twilioapp/models/sms_message.py +++ b/engine/apps/twilioapp/models/sms_message.py @@ -134,8 +134,9 @@ class SMSMessage(models.Model): except requests.exceptions.RequestException as e: logger.warning(f"Unable to send SMS through cloud. Request exception {str(e)}") raise SMSMessage.CloudSendError("Unable to send SMS through cloud: request failed") - - if response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded": + if response.status_code == status.HTTP_200_OK: + logger.info("Sent cloud sms successfully") + elif response.status_code == status.HTTP_400_BAD_REQUEST and response.json().get("error") == "limit-exceeded": raise SMSMessage.SMSLimitExceeded("Organization sms limit exceeded") elif response.status_code == status.HTTP_404_NOT_FOUND: raise SMSMessage.CloudSendError("Unable to send SMS through cloud: user not found") From 0e26568857db3277128974ee6aafc2b195e6cf23 Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Mon, 6 Jun 2022 16:07:28 -0300 Subject: [PATCH 057/132] Remove demo token related code/logic --- engine/apps/auth_token/auth.py | 7 - engine/apps/grafana_plugin/tasks/sync.py | 5 +- engine/apps/oss_installation/utils.py | 8 - engine/apps/public_api/constants.py | 66 ----- engine/apps/public_api/helpers.py | 8 +- .../public_api/serializers/integrations.py | 8 - .../public_api/serializers/schedules_base.py | 13 +- engine/apps/public_api/tests/conftest.py | 226 ----------------- .../tests/test_demo_token/__init__.py | 0 .../tests/test_demo_token/test_alerts.py | 110 -------- .../test_demo_token/test_custom_actions.py | 32 --- .../test_escalation_policies.py | 169 ------------- .../tests/test_demo_token/test_incidents.py | 82 ------ .../test_demo_token/test_integrations.py | 239 ------------------ .../test_demo_token/test_on_call_shift.py | 172 ------------- .../test_personal_notification_rules.py | 225 ----------------- .../test_demo_token/test_resolution_notes.py | 117 --------- .../tests/test_demo_token/test_routes.py | 182 ------------- .../tests/test_demo_token/test_schedules.py | 164 ------------ .../test_demo_token/test_slack_channels.py | 34 --- .../tests/test_demo_token/test_user_groups.py | 36 --- .../tests/test_demo_token/test_users.py | 91 ------- engine/apps/public_api/views/action.py | 4 +- engine/apps/public_api/views/alerts.py | 7 +- .../public_api/views/escalation_policies.py | 7 +- engine/apps/public_api/views/incidents.py | 9 +- engine/apps/public_api/views/integrations.py | 11 +- .../apps/public_api/views/on_call_shifts.py | 7 +- engine/apps/public_api/views/organizations.py | 6 +- .../views/personal_notifications.py | 7 +- .../apps/public_api/views/resolution_notes.py | 7 +- engine/apps/public_api/views/routes.py | 7 +- engine/apps/public_api/views/schedules.py | 7 +- .../apps/public_api/views/slack_channels.py | 4 +- engine/apps/public_api/views/user_groups.py | 4 +- engine/apps/public_api/views/users.py | 7 +- .../notify_about_empty_shifts_in_schedule.py | 7 +- .../tasks/notify_about_gaps_in_schedule.py | 7 +- .../schedules/tasks/refresh_ical_files.py | 5 +- engine/apps/slack/tasks.py | 11 +- engine/common/api_helpers/mixins.py | 78 ------ 41 files changed, 40 insertions(+), 2156 deletions(-) delete mode 100644 engine/apps/public_api/tests/test_demo_token/__init__.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_alerts.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_custom_actions.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_escalation_policies.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_incidents.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_integrations.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_on_call_shift.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_personal_notification_rules.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_resolution_notes.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_routes.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_schedules.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_slack_channels.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_user_groups.py delete mode 100644 engine/apps/public_api/tests/test_demo_token/test_users.py diff --git a/engine/apps/auth_token/auth.py b/engine/apps/auth_token/auth.py index aa1a6251..be4a99f3 100644 --- a/engine/apps/auth_token/auth.py +++ b/engine/apps/auth_token/auth.py @@ -9,7 +9,6 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_ from rest_framework.request import Request from apps.grafana_plugin.helpers.gcom import check_token -from apps.public_api import constants as public_api_constants from apps.user_management.models import User from apps.user_management.models.organization import Organization from common.constants.role import Role @@ -29,12 +28,6 @@ class ApiTokenAuthentication(BaseAuthentication): def authenticate(self, request): auth = get_authorization_header(request).decode("utf-8") - - if auth == public_api_constants.DEMO_AUTH_TOKEN: - user = User.objects.get(public_primary_key=public_api_constants.DEMO_USER_ID) - auth_token = user.auth_tokens.first() - return user, auth_token - user, auth_token = self.authenticate_credentials(auth) if user.role != Role.ADMIN: diff --git a/engine/apps/grafana_plugin/tasks/sync.py b/engine/apps/grafana_plugin/tasks/sync.py index 2d6c37bd..5ee38fe2 100644 --- a/engine/apps/grafana_plugin/tasks/sync.py +++ b/engine/apps/grafana_plugin/tasks/sync.py @@ -6,7 +6,6 @@ from django.utils import timezone from apps.grafana_plugin.helpers import GcomAPIClient from apps.grafana_plugin.helpers.gcom import get_active_instance_ids -from apps.public_api.constants import DEMO_ORGANIZATION_ID from apps.user_management.models import Organization from apps.user_management.sync import sync_organization from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -23,9 +22,7 @@ SYNC_PERIOD = timezone.timedelta(minutes=25) def start_sync_organizations(): sync_threshold = timezone.now() - SYNC_PERIOD - organization_qs = Organization.objects.exclude(public_primary_key=DEMO_ORGANIZATION_ID).filter( - last_time_synced__lte=sync_threshold - ) + organization_qs = Organization.objects.filter(last_time_synced__lte=sync_threshold) active_instance_ids, is_cloud_configured = get_active_instance_ids() if is_cloud_configured: diff --git a/engine/apps/oss_installation/utils.py b/engine/apps/oss_installation/utils.py index aef70aa4..c8a0e65b 100644 --- a/engine/apps/oss_installation/utils.py +++ b/engine/apps/oss_installation/utils.py @@ -1,10 +1,8 @@ import logging -from contextlib import suppress from django.apps import apps from django.utils import timezone -from apps.public_api.constants import DEMO_USER_ID from apps.schedules.ical_utils import list_users_to_notify_from_ical_for_period logger = logging.getLogger(__name__) @@ -18,7 +16,6 @@ def active_oss_users_count(): AlertGroupLogRecord = apps.get_model("alerts", "AlertGroupLogRecord") EscalationPolicy = apps.get_model("alerts", "EscalationPolicy") UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord") - User = apps.get_model("user_management", "User") # Take logs for previous 24 hours start = timezone.now() - timezone.timedelta(hours=24) @@ -67,9 +64,4 @@ def active_oss_users_count(): for user in users_from_schedule: unique_active_users.add(user.pk) - # Remove demo user from active users - with suppress(User.DoesNotExist): - demo_user = User.objects.get(public_primary_key=DEMO_USER_ID) - with suppress(KeyError): - unique_active_users.remove(demo_user.pk) return len(unique_active_users) diff --git a/engine/apps/public_api/constants.py b/engine/apps/public_api/constants.py index 4a14df3f..cd2f6e38 100644 --- a/engine/apps/public_api/constants.py +++ b/engine/apps/public_api/constants.py @@ -1,69 +1,3 @@ from django.utils import dateparse -DEMO_USER_ID = "U4DNY931HHJS5" -DEMO_ORGANIZATION_ID = "TCNPY4A1BWUMP" -DEMO_SLACK_USER_ID = "UALEXSLACKDJPK" -DEMO_SLACK_TEAM_ID = "TALEXSLACKDJPK" -DEMO_AUTH_TOKEN = "meowmeowmeow" -DEMO_USER_USERNAME = "Alex" -DEMO_USER_EMAIL = "public-api-demo-user-1@amixr.io" -DEMO_INTEGRATION_ID = "CFRPV98RPR1U8" -DEMO_INTEGRATION_LINK_TOKEN = "mReAoNwDm0eMwKo1mTeTwYo" -DEMO_INTEGRATION_NAME = "Grafana :blush:" -DEMO_ROUTE_ID_1 = "RIYGUJXCPFHXY" -DEMO_ROUTE_ID_2 = "RVBE4RKQSCGJ2" -DEMO_SLACK_CHANNEL_FOR_ROUTE_ID = "CH23212D" -DEMO_ESCALATION_CHAIN_ID = "F5JU6KJET33FE" -DEMO_ESCALATION_POLICY_ID_1 = "E3GA6SJETWWJS" -DEMO_ESCALATION_POLICY_ID_2 = "E5JJTU52M5YM4" -DEMO_SCHEDULE_ID_ICAL = "SBM7DV7BKFUYU" -DEMO_SCHEDULE_ID_CALENDAR = "S3Z477AHDXTMF" -DEMO_SCHEDULE_NAME_ICAL = "Demo schedule iCal" -DEMO_SCHEDULE_NAME_CALENDAR = "Demo schedule Calendar" -DEMO_SCHEDULE_ICAL_URL_PRIMARY = "https://example.com/meow_calendar.ics" -DEMO_SCHEDULE_ICAL_URL_OVERRIDES = "https://example.com/meow_calendar_overrides.ics" -DEMO_INCIDENT_ID = "I68T24C13IFW1" -DEMO_INCIDENT_CREATED_AT = "2020-05-19T12:37:01.430444Z" -DEMO_INCIDENT_RESOLVED_AT = "2020-05-19T13:37:01.429805Z" -DEMO_ALERT_IDS = [ - ("AA74DN7T4JQB6", "2020-05-11T20:07:43Z"), - ("AR9SSYFKE2PV7", "2020-05-11T20:07:54Z"), - ("AWJQSGEYYUFGH", "2020-05-11T20:07:58Z"), -] -DEMO_ALERT_PAYLOAD = { - "evalMatches": [ - {"value": 100, "metric": "High value", "tags": None}, - {"value": 200, "metric": "Higher Value", "tags": None}, - ], - "message": "Someone is testing the alert notification within grafana.", - "ruleId": 0, - "ruleName": "Test notification", - "ruleUrl": "https://amixr.io/", - "state": "alerting", - "title": "[Alerting] Test notification", -} VALID_DATE_FOR_DELETE_INCIDENT = dateparse.parse_date("2020-07-04") -DEMO_SLACK_CHANNEL_NAME = "meow_channel" -DEMO_SLACK_CHANNEL_SLACK_ID = "MEOW_SLACK_ID" -DEMO_PERSONAL_NOTIFICATION_ID_1 = "NT79GA9I7E4DJ" -DEMO_PERSONAL_NOTIFICATION_ID_2 = "ND9EHN5LN1DUU" -DEMO_PERSONAL_NOTIFICATION_ID_3 = "NEF49YQ1HNPDD" -DEMO_PERSONAL_NOTIFICATION_ID_4 = "NWAL6WFJNWDD8" -DEMO_RESOLUTION_NOTE_ID = "M4BTQUS3PRHYQ" -DEMO_RESOLUTION_NOTE_TEXT = "Demo resolution note" -DEMO_RESOLUTION_NOTE_CREATED_AT = "2020-06-19T12:40:01.429805Z" -DEMO_RESOLUTION_NOTE_SOURCE = "web" -DEMO_CUSTOM_ACTION_ID = "KGEFG74LU1D8L" -DEMO_CUSTOM_ACTION_NAME = "Publish Incident To Jira" -DEMO_SLACK_USER_GROUP_ID = "GPFAPH7J7BKJB" -DEMO_SLACK_USER_GROUP_SLACK_ID = "MEOW_SLACK_ID" -DEMO_SLACK_USER_GROUP_NAME = "Meow Group" -DEMO_SLACK_USER_GROUP_HANDLE = "meow_group" -DEMO_ON_CALL_SHIFT_ID_1 = "OH3V5FYQEYJ6M" -DEMO_ON_CALL_SHIFT_ID_2 = "O9WTH7CKM3KZW" -DEMO_ON_CALL_SHIFT_NAME_1 = "Demo single event" -DEMO_ON_CALL_SHIFT_NAME_2 = "Demo recurrent event" -DEMO_ON_CALL_SHIFT_START_1 = "2020-09-10T08:00:00" -DEMO_ON_CALL_SHIFT_START_2 = "2020-09-10T16:00:00" -DEMO_ON_CALL_SHIFT_DURATION = 10800 -DEMO_ON_CALL_SHIFT_BY_DAY = ["MO", "WE", "FR"] diff --git a/engine/apps/public_api/helpers.py b/engine/apps/public_api/helpers.py index f684e34a..587445cb 100644 --- a/engine/apps/public_api/helpers.py +++ b/engine/apps/public_api/helpers.py @@ -1,14 +1,8 @@ -from apps.public_api.constants import DEMO_AUTH_TOKEN, VALID_DATE_FOR_DELETE_INCIDENT +from apps.public_api.constants import VALID_DATE_FOR_DELETE_INCIDENT from apps.slack.slack_client import SlackClientWithErrorHandling from apps.slack.slack_client.exceptions import SlackAPITokenException -def is_demo_token_request(request): - if DEMO_AUTH_TOKEN == request.headers.get("Authorization"): - return True - return False - - def team_has_slack_token_for_deleting(alert_group): if alert_group.slack_message and alert_group.slack_message.slack_team_identity: sc = SlackClientWithErrorHandling(alert_group.slack_message.slack_team_identity.bot_access_token) diff --git a/engine/apps/public_api/serializers/integrations.py b/engine/apps/public_api/serializers/integrations.py index 82d418c0..090523a2 100644 --- a/engine/apps/public_api/serializers/integrations.py +++ b/engine/apps/public_api/serializers/integrations.py @@ -4,8 +4,6 @@ from rest_framework import fields, serializers from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager from apps.alerts.models import AlertReceiveChannel -from apps.public_api.constants import DEMO_INTEGRATION_LINK_TOKEN -from apps.public_api.helpers import is_demo_token_request from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField from common.api_helpers.exceptions import BadRequest from common.api_helpers.mixins import EagerLoadingMixin @@ -62,12 +60,6 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main default_route = self._get_default_route_iterative(instance) serializer = DefaultChannelFilterSerializer(default_route, context=self.context) result["default_route"] = serializer.data - if is_demo_token_request(self.context["request"]): - # Replace integration token to not receive alerts on demo integration - link = result["link"] - real_token = instance.token - link = link.replace(real_token, DEMO_INTEGRATION_LINK_TOKEN) - result["link"] = link return result diff --git a/engine/apps/public_api/serializers/schedules_base.py b/engine/apps/public_api/serializers/schedules_base.py index 80cd8bc5..8eed1cf8 100644 --- a/engine/apps/public_api/serializers/schedules_base.py +++ b/engine/apps/public_api/serializers/schedules_base.py @@ -2,8 +2,6 @@ from django.apps import apps from django.utils import timezone from rest_framework import serializers -from apps.public_api import constants as public_api_constants -from apps.public_api.helpers import is_demo_token_request from apps.schedules.ical_utils import list_users_to_notify_from_ical from apps.schedules.models import OnCallSchedule from apps.slack.models import SlackUserGroup @@ -36,14 +34,11 @@ class ScheduleBaseSerializer(serializers.ModelSerializer): raise BadRequest(detail="Schedule with this name already exists") def get_on_call_now(self, obj): - if not is_demo_token_request(self.context["request"]): - users_on_call = list_users_to_notify_from_ical(obj, timezone.datetime.now(timezone.utc)) - if users_on_call is not None: - return [user.public_primary_key for user in users_on_call] - else: - return [] + users_on_call = list_users_to_notify_from_ical(obj, timezone.datetime.now(timezone.utc)) + if users_on_call is not None: + return [user.public_primary_key for user in users_on_call] else: - return [public_api_constants.DEMO_USER_ID] + return [] def _correct_validated_data(self, validated_data): slack_field = validated_data.pop("slack", {}) diff --git a/engine/apps/public_api/tests/conftest.py b/engine/apps/public_api/tests/conftest.py index a4d11c26..f8b6f8b0 100644 --- a/engine/apps/public_api/tests/conftest.py +++ b/engine/apps/public_api/tests/conftest.py @@ -1,14 +1,7 @@ import pytest -from django.utils import dateparse, timezone from pytest_factoryboy import register -from apps.alerts.models import EscalationPolicy, ResolutionNote -from apps.auth_token.models import ApiAuthToken -from apps.base.models import UserNotificationPolicy -from apps.public_api import constants as public_api_constants -from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleICal from apps.user_management.tests.factories import OrganizationFactory, UserFactory -from common.constants.role import Role register(UserFactory) register(OrganizationFactory) @@ -22,222 +15,3 @@ def make_organization_and_user_with_token(make_organization_and_user, make_publi return organization, user, token return _make_organization_and_user_with_token - - -@pytest.fixture() -def make_organization_and_user_with_slack_identities_for_demo_token( - make_slack_team_identity, - make_organization, - make_slack_user_identity, - make_user, -): - def _make_organization_and_user_with_slack_identities_for_demo_token(): - slack_team_identity = make_slack_team_identity(slack_id=public_api_constants.DEMO_SLACK_TEAM_ID) - organization = make_organization( - slack_team_identity=slack_team_identity, public_primary_key=public_api_constants.DEMO_ORGANIZATION_ID - ) - slack_user_identity = make_slack_user_identity( - slack_id=public_api_constants.DEMO_SLACK_USER_ID, - slack_team_identity=slack_team_identity, - ) - user = make_user( - organization=organization, - public_primary_key=public_api_constants.DEMO_USER_ID, - email=public_api_constants.DEMO_USER_EMAIL, - username=public_api_constants.DEMO_USER_USERNAME, - role=Role.ADMIN, - slack_user_identity=slack_user_identity, - ) - ApiAuthToken.create_auth_token(user, organization, public_api_constants.DEMO_AUTH_TOKEN) - token = public_api_constants.DEMO_AUTH_TOKEN - return organization, user, token - - return _make_organization_and_user_with_slack_identities_for_demo_token - - -@pytest.fixture() -def make_data_for_demo_token( - make_alert_receive_channel, - make_channel_filter, - make_escalation_chain, - make_escalation_policy, - make_alert_group, - make_alert, - make_resolution_note, - make_custom_action, - make_slack_user_group, - make_schedule, - make_on_call_shift, - make_slack_channel, - make_user_notification_policy, -): - def _make_data_for_demo_token(organization, user): - alert_receive_channel = make_alert_receive_channel( - organization, - public_primary_key=public_api_constants.DEMO_INTEGRATION_ID, - verbal_name=public_api_constants.DEMO_INTEGRATION_NAME, - ) - route_1 = make_channel_filter( - public_primary_key=public_api_constants.DEMO_ROUTE_ID_1, - alert_receive_channel=alert_receive_channel, - slack_channel_id=public_api_constants.DEMO_SLACK_CHANNEL_FOR_ROUTE_ID, - filtering_term="us-(east|west)", - order=0, - ) - make_channel_filter( - public_primary_key=public_api_constants.DEMO_ROUTE_ID_2, - alert_receive_channel=alert_receive_channel, - slack_channel_id=public_api_constants.DEMO_SLACK_CHANNEL_FOR_ROUTE_ID, - filtering_term=".*", - order=1, - is_default=True, - ) - escalation_chain = make_escalation_chain( - organization, public_primary_key=public_api_constants.DEMO_ESCALATION_CHAIN_ID - ) - make_escalation_policy( - escalation_chain, - public_primary_key=public_api_constants.DEMO_ESCALATION_POLICY_ID_1, - escalation_policy_step=EscalationPolicy.STEP_WAIT, - order=0, - wait_delay=EscalationPolicy.ONE_MINUTE, - ) - escalation_policy_2 = make_escalation_policy( - escalation_chain, - public_primary_key=public_api_constants.DEMO_ESCALATION_POLICY_ID_2, - escalation_policy_step=EscalationPolicy.STEP_NOTIFY_USERS_QUEUE, - order=1, - ) - escalation_policy_2.notify_to_users_queue.add(user) - alert_group = make_alert_group( - alert_receive_channel, - public_primary_key=public_api_constants.DEMO_INCIDENT_ID, - resolved=True, - channel_filter=route_1, - ) - alert_group.started_at = dateparse.parse_datetime(public_api_constants.DEMO_INCIDENT_CREATED_AT) - alert_group.resolved_at = dateparse.parse_datetime(public_api_constants.DEMO_INCIDENT_RESOLVED_AT) - alert_group.save(update_fields=["started_at", "resolved_at"]) - for alert_id, created_at in public_api_constants.DEMO_ALERT_IDS: - alert = make_alert( - public_primary_key=alert_id, - alert_group=alert_group, - raw_request_data=public_api_constants.DEMO_ALERT_PAYLOAD, - ) - alert.created_at = dateparse.parse_datetime(created_at) - alert.save(update_fields=["created_at"]) - - resolution_note = make_resolution_note( - alert_group=alert_group, - source=ResolutionNote.Source.WEB, - author=user, - public_primary_key=public_api_constants.DEMO_RESOLUTION_NOTE_ID, - message_text=public_api_constants.DEMO_RESOLUTION_NOTE_TEXT, - ) - resolution_note.created_at = dateparse.parse_datetime(public_api_constants.DEMO_RESOLUTION_NOTE_CREATED_AT) - resolution_note.save(update_fields=["created_at"]) - - make_custom_action( - public_primary_key=public_api_constants.DEMO_CUSTOM_ACTION_ID, - organization=organization, - name=public_api_constants.DEMO_CUSTOM_ACTION_NAME, - ) - - user_group = make_slack_user_group( - public_primary_key=public_api_constants.DEMO_SLACK_USER_GROUP_ID, - name=public_api_constants.DEMO_SLACK_USER_GROUP_NAME, - handle=public_api_constants.DEMO_SLACK_USER_GROUP_HANDLE, - slack_id=public_api_constants.DEMO_SLACK_USER_GROUP_SLACK_ID, - slack_team_identity=organization.slack_team_identity, - ) - - # ical schedule - make_schedule( - organization=organization, - schedule_class=OnCallScheduleICal, - public_primary_key=public_api_constants.DEMO_SCHEDULE_ID_ICAL, - ical_url_primary=public_api_constants.DEMO_SCHEDULE_ICAL_URL_PRIMARY, - ical_url_overrides=public_api_constants.DEMO_SCHEDULE_ICAL_URL_OVERRIDES, - name=public_api_constants.DEMO_SCHEDULE_NAME_ICAL, - channel=public_api_constants.DEMO_SLACK_CHANNEL_SLACK_ID, - user_group=user_group, - ) - # calendar schedule - schedule_calendar = make_schedule( - organization=organization, - schedule_class=OnCallScheduleCalendar, - public_primary_key=public_api_constants.DEMO_SCHEDULE_ID_CALENDAR, - name=public_api_constants.DEMO_SCHEDULE_NAME_CALENDAR, - channel=public_api_constants.DEMO_SLACK_CHANNEL_SLACK_ID, - user_group=user_group, - time_zone="America/New_york", - ) - - on_call_shift_1 = make_on_call_shift( - shift_type=CustomOnCallShift.TYPE_SINGLE_EVENT, - organization=organization, - public_primary_key=public_api_constants.DEMO_ON_CALL_SHIFT_ID_1, - name=public_api_constants.DEMO_ON_CALL_SHIFT_NAME_1, - start=dateparse.parse_datetime(public_api_constants.DEMO_ON_CALL_SHIFT_START_1), - duration=timezone.timedelta(seconds=public_api_constants.DEMO_ON_CALL_SHIFT_DURATION), - ) - on_call_shift_1.users.add(user) - - on_call_shift_2 = make_on_call_shift( - shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, - organization=organization, - public_primary_key=public_api_constants.DEMO_ON_CALL_SHIFT_ID_2, - name=public_api_constants.DEMO_ON_CALL_SHIFT_NAME_2, - start=dateparse.parse_datetime(public_api_constants.DEMO_ON_CALL_SHIFT_START_2), - duration=timezone.timedelta(seconds=public_api_constants.DEMO_ON_CALL_SHIFT_DURATION), - frequency=CustomOnCallShift.FREQUENCY_WEEKLY, - interval=2, - by_day=public_api_constants.DEMO_ON_CALL_SHIFT_BY_DAY, - source=CustomOnCallShift.SOURCE_TERRAFORM, - ) - on_call_shift_2.users.add(user) - - schedule_calendar.custom_on_call_shifts.add(on_call_shift_1) - schedule_calendar.custom_on_call_shifts.add(on_call_shift_2) - - make_slack_channel( - organization.slack_team_identity, - slack_id=public_api_constants.DEMO_SLACK_CHANNEL_SLACK_ID, - name=public_api_constants.DEMO_SLACK_CHANNEL_NAME, - ) - make_user_notification_policy( - public_primary_key=public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_1, - important=False, - user=user, - notify_by=UserNotificationPolicy.NotificationChannel.SMS, - step=UserNotificationPolicy.Step.NOTIFY, - order=0, - ) - make_user_notification_policy( - public_primary_key=public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_2, - important=False, - user=user, - step=UserNotificationPolicy.Step.WAIT, - wait_delay=UserNotificationPolicy.FIVE_MINUTES, - order=1, - ) - make_user_notification_policy( - public_primary_key=public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_3, - important=False, - user=user, - step=UserNotificationPolicy.Step.NOTIFY, - notify_by=UserNotificationPolicy.NotificationChannel.PHONE_CALL, - order=2, - ) - - make_user_notification_policy( - public_primary_key=public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_4, - important=True, - user=user, - step=UserNotificationPolicy.Step.NOTIFY, - notify_by=UserNotificationPolicy.NotificationChannel.PHONE_CALL, - order=0, - ) - return - - return _make_data_for_demo_token diff --git a/engine/apps/public_api/tests/test_demo_token/__init__.py b/engine/apps/public_api/tests/test_demo_token/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/engine/apps/public_api/tests/test_demo_token/test_alerts.py b/engine/apps/public_api/tests/test_demo_token/test_alerts.py deleted file mode 100644 index 4153ca2b..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_alerts.py +++ /dev/null @@ -1,110 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient - -from apps.public_api import constants as public_api_constants - -demo_alerts_results = [] -for alert_id, created_at in public_api_constants.DEMO_ALERT_IDS: - demo_alerts_results.append( - { - "id": alert_id, - "alert_group_id": public_api_constants.DEMO_INCIDENT_ID, - "created_at": created_at, - "payload": { - "state": "alerting", - "title": "[Alerting] Test notification", - "ruleId": 0, - "message": "Someone is testing the alert notification within grafana.", - "ruleUrl": "https://amixr.io/", - "ruleName": "Test notification", - "evalMatches": [ - {"tags": None, "value": 100, "metric": "High value"}, - {"tags": None, "value": 200, "metric": "Higher Value"}, - ], - }, - } - ) - -# https://api-docs.amixr.io/#list-alerts -demo_alerts_payload = {"count": 3, "next": None, "previous": None, "results": demo_alerts_results} - - -@pytest.mark.django_db -def test_get_alerts( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - url = reverse("api-public:alerts-list") - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_alerts_payload - - -@pytest.mark.django_db -def test_get_alerts_filter_by_incident( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - url = reverse("api-public:alerts-list") - response = client.get( - url + f"?alert_group_id={public_api_constants.DEMO_INCIDENT_ID}", format="json", HTTP_AUTHORIZATION=token - ) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_alerts_payload - - -@pytest.mark.django_db -def test_get_alerts_filter_by_incident_no_results( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - url = reverse("api-public:alerts-list") - response = client.get(url + "?alert_group_id=impossible_alert_group_id", format="json", HTTP_AUTHORIZATION=token) - assert response.status_code == status.HTTP_200_OK - assert response.data["results"] == [] - - -@pytest.mark.django_db -def test_get_alerts_search( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - url = reverse("api-public:alerts-list") - response = client.get(url + "?search=evalMatches", format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_alerts_payload - - -@pytest.mark.django_db -def test_get_alerts_search_no_results( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - url = reverse("api-public:alerts-list") - response = client.get(url + "?search=impossible_payload", format="json", HTTP_AUTHORIZATION=token) - assert response.status_code == status.HTTP_200_OK - assert response.data["results"] == [] diff --git a/engine/apps/public_api/tests/test_demo_token/test_custom_actions.py b/engine/apps/public_api/tests/test_demo_token/test_custom_actions.py deleted file mode 100644 index 6cf21903..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_custom_actions.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient - -from apps.public_api import constants as public_api_constants - -demo_custom_action_payload = { - "id": public_api_constants.DEMO_CUSTOM_ACTION_ID, - "name": public_api_constants.DEMO_CUSTOM_ACTION_NAME, - "team_id": None, -} - -demo_custom_action_payload_list = {"count": 1, "next": None, "previous": None, "results": [demo_custom_action_payload]} - - -@pytest.mark.django_db -def test_demo_get_custom_actions_list( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:actions-list") - - response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_200_OK - assert response.data == demo_custom_action_payload_list diff --git a/engine/apps/public_api/tests/test_demo_token/test_escalation_policies.py b/engine/apps/public_api/tests/test_demo_token/test_escalation_policies.py deleted file mode 100644 index 4df862b6..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_escalation_policies.py +++ /dev/null @@ -1,169 +0,0 @@ -import pytest -from django.urls import reverse -from django.utils import timezone -from rest_framework import status -from rest_framework.test import APIClient - -from apps.alerts.models import EscalationPolicy -from apps.public_api import constants as public_api_constants - -# https://api-docs.amixr.io/#get-escalation-policy -demo_escalation_policy_payload = { - "id": public_api_constants.DEMO_ESCALATION_POLICY_ID_1, - "escalation_chain_id": public_api_constants.DEMO_ESCALATION_CHAIN_ID, - "position": 0, - "type": "wait", - "duration": timezone.timedelta(seconds=60).seconds, -} - -# https://api-docs.amixr.io/#list-escalation-policies -demo_escalation_policies_payload = { - "count": 2, - "next": None, - "previous": None, - "results": [ - { - "id": public_api_constants.DEMO_ESCALATION_POLICY_ID_1, - "escalation_chain_id": public_api_constants.DEMO_ESCALATION_CHAIN_ID, - "position": 0, - "type": "wait", - "duration": timezone.timedelta(seconds=60).seconds, - }, - { - "id": public_api_constants.DEMO_ESCALATION_POLICY_ID_2, - "escalation_chain_id": public_api_constants.DEMO_ESCALATION_CHAIN_ID, - "position": 1, - "type": "notify_person_next_each_time", - "persons_to_notify_next_each_time": ["U4DNY931HHJS5"], - }, - ], -} - - -@pytest.mark.django_db -def test_get_escalation_policies( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - url = reverse("api-public:escalation_policies-list") - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_escalation_policies_payload - - -@pytest.mark.django_db -def test_get_escalation_policies_filter_by_route( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - url = reverse("api-public:escalation_policies-list") - response = client.get( - url + f"?route_id={public_api_constants.DEMO_ROUTE_ID_1}", format="json", HTTP_AUTHORIZATION=token - ) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_escalation_policies_payload - - -@pytest.mark.django_db -def test_create_escalation_policy( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - data_for_create = { - "escalation_chain_id": public_api_constants.DEMO_ESCALATION_CHAIN_ID, - "type": "notify_person_next_each_time", - "position": 0, - "persons_to_notify_next_each_time": [user.public_primary_key], - } - url = reverse("api-public:escalation_policies-list") - response = client.post(url, data=data_for_create, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_201_CREATED - # check on nothing change - assert response.json() == demo_escalation_policy_payload - - -@pytest.mark.django_db -def test_invalid_step_type( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - data_for_create = { - "escalation_chain_id": public_api_constants.DEMO_ESCALATION_CHAIN_ID, - "type": "this_is_invalid_step_type", # invalid step type - "position": 0, - "persons_to_notify_next_each_time": [user.public_primary_key], - } - url = reverse("api-public:escalation_policies-list") - response = client.post(url, data=data_for_create, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_201_CREATED - # check on nothing change - assert response.json() == demo_escalation_policy_payload - - -@pytest.mark.django_db -def test_update_escalation_step( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - data_for_update = { - "route_id": public_api_constants.DEMO_ROUTE_ID_1, - "type": "notify_person_next_each_time", - "position": 1, - "persons_to_notify_next_each_time": [user.public_primary_key], - } - url = reverse( - "api-public:escalation_policies-detail", kwargs={"pk": public_api_constants.DEMO_ESCALATION_POLICY_ID_1} - ) - response = client.put(url, data=data_for_update, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - # check on nothing change - assert response.json() == demo_escalation_policy_payload - - -@pytest.mark.django_db -def test_delete_escalation_policy( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - escalation_policy = EscalationPolicy.objects.get( - public_primary_key=public_api_constants.DEMO_ESCALATION_POLICY_ID_1 - ) - - url = reverse("api-public:escalation_policies-detail", args=[escalation_policy.public_primary_key]) - response = client.delete(url, format="json", HTTP_AUTHORIZATION=token) - - escalation_policy.refresh_from_db() - - assert response.status_code == status.HTTP_204_NO_CONTENT - # check on nothing change - escalation_policy.refresh_from_db() - assert escalation_policy is not None diff --git a/engine/apps/public_api/tests/test_demo_token/test_incidents.py b/engine/apps/public_api/tests/test_demo_token/test_incidents.py deleted file mode 100644 index 26aa3b1a..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_incidents.py +++ /dev/null @@ -1,82 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient - -from apps.alerts.models import AlertGroup -from apps.public_api import constants as public_api_constants - -demo_incidents_payload = { - "count": 1, - "next": None, - "previous": None, - "results": [ - { - "id": public_api_constants.DEMO_INCIDENT_ID, - "integration_id": public_api_constants.DEMO_INTEGRATION_ID, - "route_id": public_api_constants.DEMO_ROUTE_ID_1, - "alerts_count": 3, - "state": "resolved", - "created_at": public_api_constants.DEMO_INCIDENT_CREATED_AT, - "resolved_at": public_api_constants.DEMO_INCIDENT_RESOLVED_AT, - "acknowledged_at": None, - "title": None, - } - ], -} - - -@pytest.mark.django_db -def test_create_incidents( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - url = reverse("api-public:alert_groups-list") - response = client.post(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - - -@pytest.mark.django_db -def test_get_incidents( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - url = reverse("api-public:alert_groups-list") - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_incidents_payload - - -@pytest.mark.django_db -def test_delete_incidents( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - url = reverse("api-public:alert_groups-list") - incidents = AlertGroup.unarchived_objects.filter(public_primary_key=public_api_constants.DEMO_INCIDENT_ID) - total_count = incidents.count() - incident = incidents[0] - data = { - "mode": "delete", - } - response = client.delete(url + f"/{incident.public_primary_key}/", data, format="json", HTTP_AUTHORIZATION=token) - new_count = AlertGroup.unarchived_objects.filter(public_primary_key=public_api_constants.DEMO_INCIDENT_ID).count() - - assert response.status_code == status.HTTP_204_NO_CONTENT - incident.refresh_from_db() - assert total_count == new_count - assert incident is not None diff --git a/engine/apps/public_api/tests/test_demo_token/test_integrations.py b/engine/apps/public_api/tests/test_demo_token/test_integrations.py deleted file mode 100644 index be06f367..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_integrations.py +++ /dev/null @@ -1,239 +0,0 @@ -from urllib.parse import urljoin - -import pytest -from django.conf import settings -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient - -from apps.alerts.models import AlertReceiveChannel -from apps.public_api import constants as public_api_constants - -# https://api-docs.amixr.io/#post-integration -demo_integration_post_payload = { - "id": public_api_constants.DEMO_INTEGRATION_ID, - "team_id": None, - "name": "Grafana :blush:", - "link": urljoin(settings.BASE_URL, f"/integrations/v1/grafana/{public_api_constants.DEMO_INTEGRATION_LINK_TOKEN}/"), - "heartbeat": None, - "default_route": { - "escalation_chain_id": None, - "id": public_api_constants.DEMO_ROUTE_ID_2, - "slack": {"channel_id": public_api_constants.DEMO_SLACK_CHANNEL_FOR_ROUTE_ID}, - }, - "type": "grafana", - "templates": { - "grouping_key": None, - "resolve_signal": None, - "acknowledge_signal": None, - "slack": {"title": None, "message": None, "image_url": None}, - "web": {"title": None, "message": None, "image_url": None}, - "sms": { - "title": None, - }, - "phone_call": { - "title": None, - }, - "email": { - "title": None, - "message": None, - }, - "telegram": { - "title": None, - "message": None, - "image_url": None, - }, - }, - "maintenance_mode": None, - "maintenance_started_at": None, - "maintenance_end_at": None, -} - -# https://api-docs.amixr.io/#get-integration -demo_integration_payload = { - "id": public_api_constants.DEMO_INTEGRATION_ID, - "team_id": None, - "name": "Grafana :blush:", - "link": urljoin(settings.BASE_URL, f"/integrations/v1/grafana/{public_api_constants.DEMO_INTEGRATION_LINK_TOKEN}/"), - "default_route": { - "escalation_chain_id": None, - "id": public_api_constants.DEMO_ROUTE_ID_2, - "slack": {"channel_id": public_api_constants.DEMO_SLACK_CHANNEL_FOR_ROUTE_ID}, - }, - "type": "grafana", - "heartbeat": None, - "templates": { - "grouping_key": None, - "resolve_signal": None, - "acknowledge_signal": None, - "slack": {"title": None, "message": None, "image_url": None}, - "web": {"title": None, "message": None, "image_url": None}, - "sms": { - "title": None, - }, - "phone_call": { - "title": None, - }, - "email": { - "title": None, - "message": None, - }, - "telegram": { - "title": None, - "message": None, - "image_url": None, - }, - }, - "maintenance_mode": None, - "maintenance_started_at": None, - "maintenance_end_at": None, -} - -# https://api-docs.amixr.io/#list-integrations -demo_integrations_payload = { - "count": 1, - "next": None, - "previous": None, - "results": [ - { - "id": public_api_constants.DEMO_INTEGRATION_ID, - "team_id": None, - "name": "Grafana :blush:", - "link": urljoin( - settings.BASE_URL, f"/integrations/v1/grafana/{public_api_constants.DEMO_INTEGRATION_LINK_TOKEN}/" - ), - "default_route": { - "escalation_chain_id": None, - "id": public_api_constants.DEMO_ROUTE_ID_2, - "slack": {"channel_id": public_api_constants.DEMO_SLACK_CHANNEL_FOR_ROUTE_ID}, - }, - "type": "grafana", - "heartbeat": None, - "templates": { - "grouping_key": None, - "resolve_signal": None, - "acknowledge_signal": None, - "slack": { - "title": None, - "message": None, - "image_url": None, - }, - "web": {"title": None, "message": None, "image_url": None}, - "sms": { - "title": None, - }, - "phone_call": { - "title": None, - }, - "email": { - "title": None, - "message": None, - }, - "telegram": { - "title": None, - "message": None, - "image_url": None, - }, - }, - "maintenance_mode": None, - "maintenance_started_at": None, - "maintenance_end_at": None, - }, - ], -} - - -@pytest.mark.django_db -def test_get_integrations( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - url = reverse("api-public:integrations-list") - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_integrations_payload - - -@pytest.mark.django_db -def test_create_integration( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - data_for_create = {"type": "grafana"} - url = reverse("api-public:integrations-list") - response = client.post(url, data=data_for_create, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_201_CREATED - # check on nothing change - assert response.json() == demo_integration_post_payload - - -@pytest.mark.django_db -def test_update_integration( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - integration = AlertReceiveChannel.objects.get(public_primary_key=public_api_constants.DEMO_INTEGRATION_ID) - data_for_update = {"name": "new_name"} - url = reverse("api-public:integrations-detail", args=[integration.public_primary_key]) - response = client.put(url, data=data_for_update, format="json", HTTP_AUTHORIZATION=token) - - integration.refresh_from_db() - - assert response.status_code == status.HTTP_200_OK - # check on nothing change - assert response.json() == demo_integration_payload - - -@pytest.mark.django_db -def test_invalid_integration_type( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - data_for_create = {"type": "this_is_invalid_integration_type"} - url = reverse("api-public:integrations-list") - response = client.post(url, data=data_for_create, format="json", HTTP_AUTHORIZATION=token) - assert response.status_code == status.HTTP_201_CREATED - # check on nothing change - assert response.json() == demo_integration_post_payload - - -@pytest.mark.django_db -def test_delete_integration( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - integration = AlertReceiveChannel.objects.get(public_primary_key=public_api_constants.DEMO_INTEGRATION_ID) - - url = reverse("api-public:integrations-detail", args=[integration.public_primary_key]) - response = client.delete(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_204_NO_CONTENT - # check on nothing change - integration.refresh_from_db() - assert integration is not None diff --git a/engine/apps/public_api/tests/test_demo_token/test_on_call_shift.py b/engine/apps/public_api/tests/test_demo_token/test_on_call_shift.py deleted file mode 100644 index f4c4552d..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_on_call_shift.py +++ /dev/null @@ -1,172 +0,0 @@ -import pytest -from django.urls import reverse -from django.utils import timezone -from rest_framework import status -from rest_framework.test import APIClient - -from apps.public_api import constants as public_api_constants -from apps.schedules.models import CustomOnCallShift - -demo_on_call_shift_payload_1 = { - "id": public_api_constants.DEMO_ON_CALL_SHIFT_ID_1, - "team_id": None, - "name": public_api_constants.DEMO_ON_CALL_SHIFT_NAME_1, - "type": "single_event", - "time_zone": None, - "level": 0, - "start": public_api_constants.DEMO_ON_CALL_SHIFT_START_1, - "duration": public_api_constants.DEMO_ON_CALL_SHIFT_DURATION, - "users": [public_api_constants.DEMO_USER_ID], -} - -demo_on_call_shift_payload_2 = { - "id": public_api_constants.DEMO_ON_CALL_SHIFT_ID_2, - "team_id": None, - "name": public_api_constants.DEMO_ON_CALL_SHIFT_NAME_2, - "type": "recurrent_event", - "time_zone": None, - "level": 0, - "start": public_api_constants.DEMO_ON_CALL_SHIFT_START_2, - "duration": public_api_constants.DEMO_ON_CALL_SHIFT_DURATION, - "frequency": "weekly", - "interval": 2, - "week_start": "SU", - "users": [public_api_constants.DEMO_USER_ID], - "by_day": public_api_constants.DEMO_ON_CALL_SHIFT_BY_DAY, - "by_month": None, - "by_monthday": None, -} - -demo_on_call_shift_payload_list = { - "count": 2, - "next": None, - "previous": None, - "results": [demo_on_call_shift_payload_1, demo_on_call_shift_payload_2], -} - - -@pytest.mark.django_db -def test_demo_get_on_call_shift_list( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:on_call_shifts-list") - - response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_200_OK - assert response.data == demo_on_call_shift_payload_list - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "demo_on_call_shift_id,payload", - [ - (public_api_constants.DEMO_ON_CALL_SHIFT_ID_1, demo_on_call_shift_payload_1), - (public_api_constants.DEMO_ON_CALL_SHIFT_ID_2, demo_on_call_shift_payload_2), - ], -) -def test_demo_get_on_call_shift_1( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, - demo_on_call_shift_id, - payload, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:on_call_shifts-detail", kwargs={"pk": demo_on_call_shift_id}) - - response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_200_OK - assert response.data == payload - - -@pytest.mark.django_db -def test_demo_post_on_call_shift( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:on_call_shifts-list") - - data = { - "schedule_id": public_api_constants.DEMO_SCHEDULE_ID_CALENDAR, - "name": "New demo shift", - "type": CustomOnCallShift.TYPE_SINGLE_EVENT, - "start": timezone.now().replace(tzinfo=None, microsecond=0).isoformat(), - "duration": 3600, - } - - response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_201_CREATED - assert response.data == demo_on_call_shift_payload_1 - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "demo_on_call_shift_id,payload", - [ - (public_api_constants.DEMO_ON_CALL_SHIFT_ID_1, demo_on_call_shift_payload_1), - (public_api_constants.DEMO_ON_CALL_SHIFT_ID_2, demo_on_call_shift_payload_2), - ], -) -def test_demo_update_on_call_shift( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, - demo_on_call_shift_id, - payload, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - data = {"name": "Updated demo name"} - - url = reverse("api-public:on_call_shifts-detail", kwargs={"pk": demo_on_call_shift_id}) - - response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_200_OK - assert response.data == payload - - -@pytest.mark.django_db -@pytest.mark.parametrize( - "demo_on_call_shift_id", - [ - public_api_constants.DEMO_ON_CALL_SHIFT_ID_1, - public_api_constants.DEMO_ON_CALL_SHIFT_ID_2, - ], -) -def test_demo_delete_on_call_shift( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, - demo_on_call_shift_id, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:on_call_shifts-detail", kwargs={"pk": demo_on_call_shift_id}) - - response = client.delete(url, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_204_NO_CONTENT - assert CustomOnCallShift.objects.filter(public_primary_key=demo_on_call_shift_id).exists() diff --git a/engine/apps/public_api/tests/test_demo_token/test_personal_notification_rules.py b/engine/apps/public_api/tests/test_demo_token/test_personal_notification_rules.py deleted file mode 100644 index d0abf315..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_personal_notification_rules.py +++ /dev/null @@ -1,225 +0,0 @@ -import pytest -from django.urls import reverse -from django.utils import timezone -from rest_framework import status -from rest_framework.test import APIClient - -from apps.base.models import UserNotificationPolicy -from apps.base.models.user_notification_policy import NotificationChannelPublicAPIOptions -from apps.public_api import constants as public_api_constants - -TYPE_WAIT = "wait" - -demo_personal_notification_rule_payload_1 = { - "id": public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_1, - "user_id": public_api_constants.DEMO_USER_ID, - "position": 0, - "important": False, - "type": "notify_by_sms", -} - -demo_personal_notification_rule_payload_2 = { - "id": public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_2, - "user_id": public_api_constants.DEMO_USER_ID, - "position": 1, - "duration": timezone.timedelta(seconds=300).seconds, - "important": False, - "type": "wait", -} - -demo_personal_notification_rule_payload_3 = { - "id": public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_3, - "user_id": public_api_constants.DEMO_USER_ID, - "position": 2, - "important": False, - "type": "notify_by_phone_call", -} - -demo_personal_notification_rule_payload_4 = { - "id": public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_4, - "user_id": public_api_constants.DEMO_USER_ID, - "position": 0, - "important": True, - "type": "notify_by_phone_call", -} - -demo_personal_notification_rules_payload = { - "count": 4, - "next": None, - "previous": None, - "results": [ - demo_personal_notification_rule_payload_1, - demo_personal_notification_rule_payload_2, - demo_personal_notification_rule_payload_3, - demo_personal_notification_rule_payload_4, - ], -} - -demo_personal_notification_rules_non_important_payload = { - "count": 3, - "next": None, - "previous": None, - "results": [ - demo_personal_notification_rule_payload_1, - demo_personal_notification_rule_payload_2, - demo_personal_notification_rule_payload_3, - ], -} - -demo_personal_notification_rules_important_payload = { - "count": 1, - "next": None, - "previous": None, - "results": [ - demo_personal_notification_rule_payload_4, - ], -} - - -@pytest.mark.django_db -def test_get_personal_notification_rule( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - _ = make_data_for_demo_token(organization, user) - - demo_personal_notification_rule_1 = UserNotificationPolicy.objects.get( - public_primary_key=public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_1 - ) - client = APIClient() - - url = reverse( - "api-public:personal_notification_rules-detail", - kwargs={"pk": demo_personal_notification_rule_1.public_primary_key}, - ) - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_personal_notification_rule_payload_1 - - -@pytest.mark.django_db -def test_get_personal_notification_rules_list( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - _ = make_data_for_demo_token(organization, user) - - client = APIClient() - - url = reverse("api-public:personal_notification_rules-list") - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_personal_notification_rules_payload - - -@pytest.mark.django_db -def test_get_personal_notification_rules_list_important( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - _ = make_data_for_demo_token(organization, user) - client = APIClient() - - url = reverse("api-public:personal_notification_rules-list") - response = client.get(url + "?important=true", format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_personal_notification_rules_important_payload - - -@pytest.mark.django_db -def test_get_personal_notification_rules_list_non_important( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - _ = make_data_for_demo_token(organization, user) - - client = APIClient() - - url = reverse("api-public:personal_notification_rules-list") - response = client.get(url + "?important=false", format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_personal_notification_rules_non_important_payload - - -@pytest.mark.django_db -def test_update_personal_notification_rule( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - _ = make_data_for_demo_token(organization, user) - demo_personal_notification_rule_1 = UserNotificationPolicy.objects.get( - public_primary_key=public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_1 - ) - client = APIClient() - - url = reverse( - "api-public:personal_notification_rules-detail", - kwargs={"pk": demo_personal_notification_rule_1.public_primary_key}, - ) - - data_to_update = { - "type": NotificationChannelPublicAPIOptions.LABELS[UserNotificationPolicy.NotificationChannel.SLACK] - } - response = client.put(url, format="json", HTTP_AUTHORIZATION=token, data=data_to_update) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_personal_notification_rule_payload_1 - # check on nothing change - demo_personal_notification_rule_1.refresh_from_db() - assert demo_personal_notification_rule_1.notify_by != UserNotificationPolicy.NotificationChannel.SLACK - - -@pytest.mark.django_db -def test_create_personal_notification_rule( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - _ = make_data_for_demo_token(organization, user) - client = APIClient() - - url = reverse("api-public:personal_notification_rules-list") - data_for_create = { - "user_id": user.public_primary_key, - "type": TYPE_WAIT, - "position": 1, - "duration": timezone.timedelta(seconds=300).seconds, - } - response = client.post(url, format="json", HTTP_AUTHORIZATION=token, data=data_for_create) - - assert response.status_code == status.HTTP_201_CREATED - assert response.json() == demo_personal_notification_rule_payload_1 - - -@pytest.mark.django_db -def test_delete_personal_notification_rule( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - _ = make_data_for_demo_token(organization, user) - demo_personal_notification_rule_1 = UserNotificationPolicy.objects.get( - public_primary_key=public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_1 - ) - client = APIClient() - - url = reverse( - "api-public:personal_notification_rules-detail", - kwargs={"pk": demo_personal_notification_rule_1.public_primary_key}, - ) - - response = client.delete(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_204_NO_CONTENT - # check on nothing change - demo_personal_notification_rule_1.refresh_from_db() - assert demo_personal_notification_rule_1 is not None diff --git a/engine/apps/public_api/tests/test_demo_token/test_resolution_notes.py b/engine/apps/public_api/tests/test_demo_token/test_resolution_notes.py deleted file mode 100644 index 888760e9..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_resolution_notes.py +++ /dev/null @@ -1,117 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient - -from apps.alerts.models import ResolutionNote -from apps.public_api import constants as public_api_constants - -demo_resolution_note_payload = { - "id": public_api_constants.DEMO_RESOLUTION_NOTE_ID, - "alert_group_id": public_api_constants.DEMO_INCIDENT_ID, - "author": public_api_constants.DEMO_USER_ID, - "source": public_api_constants.DEMO_RESOLUTION_NOTE_SOURCE, - "created_at": public_api_constants.DEMO_RESOLUTION_NOTE_CREATED_AT, - "text": public_api_constants.DEMO_RESOLUTION_NOTE_TEXT, -} - -demo_resolution_note_payload_list = { - "count": 1, - "next": None, - "previous": None, - "results": [demo_resolution_note_payload], -} - - -@pytest.mark.django_db -def test_demo_get_resolution_note_list( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:resolution_notes-list") - - response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_200_OK - assert response.data == demo_resolution_note_payload_list - - -@pytest.mark.django_db -def test_demo_get_resolution_note( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:resolution_notes-detail", kwargs={"pk": public_api_constants.DEMO_RESOLUTION_NOTE_ID}) - - response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_200_OK - assert response.data == demo_resolution_note_payload - - -@pytest.mark.django_db -def test_demo_post_resolution_note( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:resolution_notes-list") - - data = {"alert_group_id": public_api_constants.DEMO_INCIDENT_ID, "text": "New demo text"} - - response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_201_CREATED - assert response.data == demo_resolution_note_payload - - -@pytest.mark.django_db -def test_demo_update_resolution_note( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - data = {"alert_group_id": public_api_constants.DEMO_INCIDENT_ID, "text": "Updated demo text"} - - url = reverse("api-public:resolution_notes-detail", kwargs={"pk": public_api_constants.DEMO_RESOLUTION_NOTE_ID}) - - response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_200_OK - assert response.data == demo_resolution_note_payload - - -@pytest.mark.django_db -def test_demo_delete_resolution_note( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:resolution_notes-detail", kwargs={"pk": public_api_constants.DEMO_RESOLUTION_NOTE_ID}) - - response = client.delete(url, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_204_NO_CONTENT - assert ResolutionNote.objects.filter(public_primary_key=public_api_constants.DEMO_RESOLUTION_NOTE_ID).exists() diff --git a/engine/apps/public_api/tests/test_demo_token/test_routes.py b/engine/apps/public_api/tests/test_demo_token/test_routes.py deleted file mode 100644 index cd8938db..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_routes.py +++ /dev/null @@ -1,182 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient - -from apps.alerts.models import ChannelFilter -from apps.public_api import constants as public_api_constants - -# https://api-docs.amixr.io/#get-route -demo_route_payload = { - "id": public_api_constants.DEMO_ROUTE_ID_1, - "escalation_chain_id": None, - "integration_id": public_api_constants.DEMO_INTEGRATION_ID, - "routing_regex": "us-(east|west)", - "position": 0, - "is_the_last_route": False, - "slack": {"channel_id": public_api_constants.DEMO_SLACK_CHANNEL_FOR_ROUTE_ID}, -} - -# https://api-docs.amixr.io/#list-routes -demo_routes_payload = { - "count": 2, - "next": None, - "previous": None, - "results": [ - { - "id": public_api_constants.DEMO_ROUTE_ID_1, - "escalation_chain_id": None, - "integration_id": public_api_constants.DEMO_INTEGRATION_ID, - "routing_regex": "us-(east|west)", - "position": 0, - "is_the_last_route": False, - "slack": {"channel_id": public_api_constants.DEMO_SLACK_CHANNEL_FOR_ROUTE_ID}, - }, - { - "id": public_api_constants.DEMO_ROUTE_ID_2, - "escalation_chain_id": None, - "integration_id": public_api_constants.DEMO_INTEGRATION_ID, - "routing_regex": ".*", - "position": 1, - "is_the_last_route": True, - "slack": {"channel_id": public_api_constants.DEMO_SLACK_CHANNEL_FOR_ROUTE_ID}, - }, - ], -} - - -@pytest.mark.django_db -def test_get_route( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - channel_filter = ChannelFilter.objects.get(public_primary_key=public_api_constants.DEMO_ROUTE_ID_1) - - url = reverse("api-public:routes-detail", kwargs={"pk": channel_filter.public_primary_key}) - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_route_payload - - -@pytest.mark.django_db -def test_get_routes_list( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:routes-list") - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_routes_payload - - -@pytest.mark.django_db -def test_get_routes_filter_by_integration_id( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:routes-list") - response = client.get( - url + f"?integration_id={public_api_constants.DEMO_INTEGRATION_ID}", format="json", HTTP_AUTHORIZATION=token - ) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_routes_payload - - -@pytest.mark.django_db -def test_create_route( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:routes-list") - data_for_create = { - "integration_id": public_api_constants.DEMO_INTEGRATION_ID, - "routing_regex": "testreg", - } - response = client.post(url, format="json", HTTP_AUTHORIZATION=token, data=data_for_create) - - assert response.status_code == status.HTTP_201_CREATED - assert response.json() == demo_route_payload - - -@pytest.mark.django_db -def test_invalid_route_data( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:routes-list") - data_for_create = { - "integration_id": public_api_constants.DEMO_INTEGRATION_ID, - "routing_regex": None, # routing_regex cannot be null for non-default filters - } - response = client.post(url, format="json", HTTP_AUTHORIZATION=token, data=data_for_create) - - assert response.status_code == status.HTTP_201_CREATED - assert response.json() == demo_route_payload - - -@pytest.mark.django_db -def test_update_route( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - channel_filter = ChannelFilter.objects.get(public_primary_key=public_api_constants.DEMO_ROUTE_ID_1) - - url = reverse("api-public:routes-detail", kwargs={"pk": channel_filter.public_primary_key}) - data_to_update = { - "routing_regex": "testreg_updated", - } - - assert channel_filter.filtering_term != data_to_update["routing_regex"] - - response = client.put(url, format="json", HTTP_AUTHORIZATION=token, data=data_to_update) - - assert response.status_code == status.HTTP_200_OK - # check on nothing change - channel_filter.refresh_from_db() - assert response.json() == demo_route_payload - assert channel_filter.filtering_term != data_to_update["routing_regex"] - - -@pytest.mark.django_db -def test_delete_route( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - channel_filter = ChannelFilter.objects.get(public_primary_key=public_api_constants.DEMO_ROUTE_ID_1) - - url = reverse("api-public:routes-detail", kwargs={"pk": channel_filter.public_primary_key}) - response = client.delete(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_204_NO_CONTENT - # check on nothing change - channel_filter.refresh_from_db() - assert channel_filter is not None diff --git a/engine/apps/public_api/tests/test_demo_token/test_schedules.py b/engine/apps/public_api/tests/test_demo_token/test_schedules.py deleted file mode 100644 index 9a56955b..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_schedules.py +++ /dev/null @@ -1,164 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient - -from apps.public_api import constants as public_api_constants -from apps.schedules.models import OnCallSchedule - -demo_ical_schedule_payload = { - "id": public_api_constants.DEMO_SCHEDULE_ID_ICAL, - "team_id": None, - "name": public_api_constants.DEMO_SCHEDULE_NAME_ICAL, - "type": "ical", - "ical_url_primary": public_api_constants.DEMO_SCHEDULE_ICAL_URL_PRIMARY, - "ical_url_overrides": public_api_constants.DEMO_SCHEDULE_ICAL_URL_OVERRIDES, - "on_call_now": [public_api_constants.DEMO_USER_ID], - "slack": { - "channel_id": public_api_constants.DEMO_SLACK_CHANNEL_SLACK_ID, - "user_group_id": public_api_constants.DEMO_SLACK_USER_GROUP_SLACK_ID, - }, -} - -demo_calendar_schedule_payload = { - "id": public_api_constants.DEMO_SCHEDULE_ID_CALENDAR, - "team_id": None, - "name": public_api_constants.DEMO_SCHEDULE_NAME_CALENDAR, - "type": "calendar", - "time_zone": "America/New_york", - "on_call_now": [public_api_constants.DEMO_USER_ID], - "shifts": [ - public_api_constants.DEMO_ON_CALL_SHIFT_ID_1, - public_api_constants.DEMO_ON_CALL_SHIFT_ID_2, - ], - "slack": { - "channel_id": public_api_constants.DEMO_SLACK_CHANNEL_SLACK_ID, - "user_group_id": public_api_constants.DEMO_SLACK_USER_GROUP_SLACK_ID, - }, - "ical_url_overrides": None, -} - -demo_schedules_payload = { - "count": 2, - "next": None, - "previous": None, - "results": [ - demo_ical_schedule_payload, - demo_calendar_schedule_payload, - ], -} - - -@pytest.mark.django_db -def test_get_schedule( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - schedule = OnCallSchedule.objects.get(public_primary_key=public_api_constants.DEMO_SCHEDULE_ID_ICAL) - - url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) - - response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_200_OK - assert response.data == demo_ical_schedule_payload - - -@pytest.mark.django_db -def test_create_schedule( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:schedules-list") - - data = { - "name": "schedule test name", - "type": "ical", - } - - response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_201_CREATED - # check that demo instance was returned - assert response.data == demo_ical_schedule_payload - - -@pytest.mark.django_db -def test_update_ical_schedule( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - schedule = OnCallSchedule.objects.get(public_primary_key=public_api_constants.DEMO_SCHEDULE_ID_ICAL) - - url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) - - data = { - "name": "NEW NAME", - } - - response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - # check on nothing change - schedule.refresh_from_db() - assert schedule.name != data["name"] - assert response.data == demo_ical_schedule_payload - - -@pytest.mark.django_db -def test_update_calendar_schedule( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - schedule = OnCallSchedule.objects.get(public_primary_key=public_api_constants.DEMO_SCHEDULE_ID_CALENDAR) - - url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) - - data = { - "name": "NEW NAME", - } - - response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - # check on nothing change - schedule.refresh_from_db() - assert schedule.name != data["name"] - assert response.data == demo_calendar_schedule_payload - - -@pytest.mark.django_db -def test_delete_schedule( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - schedule = OnCallSchedule.objects.get(public_primary_key=public_api_constants.DEMO_SCHEDULE_ID_ICAL) - - url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key}) - - response = client.delete(url, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_204_NO_CONTENT - # check on nothing change - schedule.refresh_from_db() - assert schedule is not None diff --git a/engine/apps/public_api/tests/test_demo_token/test_slack_channels.py b/engine/apps/public_api/tests/test_demo_token/test_slack_channels.py deleted file mode 100644 index 80a11bdc..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_slack_channels.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient - -from apps.public_api import constants as public_api_constants - -demo_slack_channels_payload = { - "count": 1, - "next": None, - "previous": None, - "results": [ - { - "name": public_api_constants.DEMO_SLACK_CHANNEL_NAME, - "slack_id": public_api_constants.DEMO_SLACK_CHANNEL_SLACK_ID, - } - ], -} - - -@pytest.mark.django_db -def test_get_slack_channels_list( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:slack_channels-list") - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_slack_channels_payload diff --git a/engine/apps/public_api/tests/test_demo_token/test_user_groups.py b/engine/apps/public_api/tests/test_demo_token/test_user_groups.py deleted file mode 100644 index 08ee995c..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_user_groups.py +++ /dev/null @@ -1,36 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient - -from apps.public_api import constants as public_api_constants - -demo_user_group_payload = { - "id": public_api_constants.DEMO_SLACK_USER_GROUP_ID, - "type": "slack_based", - "slack": { - "id": public_api_constants.DEMO_SLACK_USER_GROUP_SLACK_ID, - "name": public_api_constants.DEMO_SLACK_USER_GROUP_NAME, - "handle": public_api_constants.DEMO_SLACK_USER_GROUP_HANDLE, - }, -} - -demo_user_group_payload_list = {"count": 1, "next": None, "previous": None, "results": [demo_user_group_payload]} - - -@pytest.mark.django_db -def test_demo_get_user_groups_list( - make_organization_and_user_with_slack_identities_for_demo_token, - make_data_for_demo_token, -): - - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - client = APIClient() - _ = make_data_for_demo_token(organization, user) - - url = reverse("api-public:user_groups-list") - - response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}") - - assert response.status_code == status.HTTP_200_OK - assert response.data == demo_user_group_payload_list diff --git a/engine/apps/public_api/tests/test_demo_token/test_users.py b/engine/apps/public_api/tests/test_demo_token/test_users.py deleted file mode 100644 index ffa4bfdb..00000000 --- a/engine/apps/public_api/tests/test_demo_token/test_users.py +++ /dev/null @@ -1,91 +0,0 @@ -import pytest -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient - -from apps.public_api import constants as public_api_constants - -# NB can compare with https://api-docs.amixr.io/#get-user - -demo_token_user_payload = { - "id": public_api_constants.DEMO_USER_ID, - "email": public_api_constants.DEMO_USER_EMAIL, - "slack": {"user_id": public_api_constants.DEMO_SLACK_USER_ID, "team_id": public_api_constants.DEMO_SLACK_TEAM_ID}, - "username": public_api_constants.DEMO_USER_USERNAME, - "role": "admin", - "is_phone_number_verified": False, -} - -# https://api-docs.amixr.io/#list-users -demo_token_users_payload = { - "count": 1, - "next": None, - "previous": None, - "results": [ - { - "id": public_api_constants.DEMO_USER_ID, - "email": public_api_constants.DEMO_USER_EMAIL, - "slack": { - "user_id": public_api_constants.DEMO_SLACK_USER_ID, - "team_id": public_api_constants.DEMO_SLACK_TEAM_ID, - }, - "username": public_api_constants.DEMO_USER_USERNAME, - "role": "admin", - "is_phone_number_verified": False, - } - ], -} - - -@pytest.mark.django_db -def test_get_user( - make_organization_and_user_with_slack_identities_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - - url = reverse("api-public:users-detail", args=[user.public_primary_key]) - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_token_user_payload - - # get current user - url = reverse("api-public:users-detail", args=["current"]) - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_token_user_payload - - -@pytest.mark.django_db -def test_get_users( - make_organization_and_user_with_slack_identities_for_demo_token, -): - organization, user, token = make_organization_and_user_with_slack_identities_for_demo_token() - - client = APIClient() - - url = reverse("api-public:users-list") - response = client.get(url, format="json", HTTP_AUTHORIZATION=token) - - assert response.status_code == status.HTTP_200_OK - assert response.json() == demo_token_users_payload - - -@pytest.mark.django_db -def test_forbidden_access( - make_organization_and_user_with_slack_identities_for_demo_token, - make_organization_and_user_with_token, -): - _, user, _ = make_organization_and_user_with_slack_identities_for_demo_token() - _, _, another_org_token = make_organization_and_user_with_token() - - client = APIClient() - - url = reverse("api-public:users-detail", args=[user.public_primary_key]) - - response = client.get(url, format="json", HTTP_AUTHORIZATION=another_org_token) - - assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/engine/apps/public_api/views/action.py b/engine/apps/public_api/views/action.py index bbb6bc73..60ca1465 100644 --- a/engine/apps/public_api/views/action.py +++ b/engine/apps/public_api/views/action.py @@ -8,11 +8,11 @@ from apps.auth_token.auth import ApiTokenAuthentication from apps.public_api.serializers.action import ActionSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from common.api_helpers.filters import ByTeamFilter -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin +from common.api_helpers.mixins import RateLimitHeadersMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class ActionView(RateLimitHeadersMixin, DemoTokenMixin, mixins.ListModelMixin, GenericViewSet): +class ActionView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) pagination_class = FiftyPageSizePaginator diff --git a/engine/apps/public_api/views/alerts.py b/engine/apps/public_api/views/alerts.py index 56fe651e..da332176 100644 --- a/engine/apps/public_api/views/alerts.py +++ b/engine/apps/public_api/views/alerts.py @@ -6,14 +6,13 @@ from rest_framework.viewsets import GenericViewSet from apps.alerts.models import Alert from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api import constants as public_api_constants from apps.public_api.serializers.alerts import AlertSerializer from apps.public_api.throttlers.user_throttle import UserThrottle -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin +from common.api_helpers.mixins import RateLimitHeadersMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class AlertView(RateLimitHeadersMixin, DemoTokenMixin, mixins.ListModelMixin, GenericViewSet): +class AlertView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) @@ -23,8 +22,6 @@ class AlertView(RateLimitHeadersMixin, DemoTokenMixin, mixins.ListModelMixin, Ge serializer_class = AlertSerializer pagination_class = FiftyPageSizePaginator - demo_default_id = public_api_constants.DEMO_ALERT_IDS[0] - def get_queryset(self): alert_group_id = self.request.query_params.get("alert_group_id", None) search = self.request.query_params.get("search", None) diff --git a/engine/apps/public_api/views/escalation_policies.py b/engine/apps/public_api/views/escalation_policies.py index fc285588..15203f63 100644 --- a/engine/apps/public_api/views/escalation_policies.py +++ b/engine/apps/public_api/views/escalation_policies.py @@ -5,15 +5,14 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import EscalationPolicy from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api import constants as public_api_constants from apps.public_api.serializers import EscalationPolicySerializer, EscalationPolicyUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin, UpdateSerializerMixin +from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class EscalationPolicyView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSerializerMixin, ModelViewSet): +class EscalationPolicyView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) @@ -25,8 +24,6 @@ class EscalationPolicyView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSerializ pagination_class = FiftyPageSizePaginator - demo_default_id = public_api_constants.DEMO_ESCALATION_POLICY_ID_1 - def get_queryset(self): escalation_chain_id = self.request.query_params.get("escalation_chain_id", None) queryset = EscalationPolicy.objects.filter( diff --git a/engine/apps/public_api/views/incidents.py b/engine/apps/public_api/views/incidents.py index 1bfe830e..cd4d6098 100644 --- a/engine/apps/public_api/views/incidents.py +++ b/engine/apps/public_api/views/incidents.py @@ -8,14 +8,13 @@ from rest_framework.viewsets import GenericViewSet from apps.alerts.models import AlertGroup from apps.alerts.tasks import delete_alert_group, wipe from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api import constants as public_api_constants from apps.public_api.constants import VALID_DATE_FOR_DELETE_INCIDENT from apps.public_api.helpers import is_valid_group_creation_date, team_has_slack_token_for_deleting from apps.public_api.serializers import IncidentSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from common.api_helpers.exceptions import BadRequest from common.api_helpers.filters import ByTeamModelFieldFilterMixin, get_team_queryset -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin +from common.api_helpers.mixins import RateLimitHeadersMixin from common.api_helpers.paginators import FiftyPageSizePaginator @@ -30,9 +29,7 @@ class IncidentByTeamFilter(ByTeamModelFieldFilterMixin, filters.FilterSet): ) -class IncidentView( - RateLimitHeadersMixin, DemoTokenMixin, mixins.ListModelMixin, mixins.DestroyModelMixin, GenericViewSet -): +class IncidentView(RateLimitHeadersMixin, mixins.ListModelMixin, mixins.DestroyModelMixin, GenericViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) @@ -42,8 +39,6 @@ class IncidentView( serializer_class = IncidentSerializer pagination_class = FiftyPageSizePaginator - demo_default_id = public_api_constants.DEMO_INCIDENT_ID - filter_backends = (filters.DjangoFilterBackend,) filterset_class = IncidentByTeamFilter diff --git a/engine/apps/public_api/views/integrations.py b/engine/apps/public_api/views/integrations.py index 8aa4784e..0e5fac35 100644 --- a/engine/apps/public_api/views/integrations.py +++ b/engine/apps/public_api/views/integrations.py @@ -6,17 +6,11 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import AlertReceiveChannel from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api import constants as public_api_constants from apps.public_api.serializers import IntegrationSerializer, IntegrationUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log from common.api_helpers.filters import ByTeamFilter -from common.api_helpers.mixins import ( - DemoTokenMixin, - FilterSerializerMixin, - RateLimitHeadersMixin, - UpdateSerializerMixin, -) +from common.api_helpers.mixins import FilterSerializerMixin, RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator from .maintaiable_object_mixin import MaintainableObjectMixin @@ -24,7 +18,6 @@ from .maintaiable_object_mixin import MaintainableObjectMixin class IntegrationView( RateLimitHeadersMixin, - DemoTokenMixin, FilterSerializerMixin, UpdateSerializerMixin, MaintainableObjectMixin, @@ -41,8 +34,6 @@ class IntegrationView( pagination_class = FiftyPageSizePaginator - demo_default_id = public_api_constants.DEMO_INTEGRATION_ID - filter_backends = (filters.DjangoFilterBackend,) filterset_class = ByTeamFilter diff --git a/engine/apps/public_api/views/on_call_shifts.py b/engine/apps/public_api/views/on_call_shifts.py index 5f366f19..1d0df97a 100644 --- a/engine/apps/public_api/views/on_call_shifts.py +++ b/engine/apps/public_api/views/on_call_shifts.py @@ -4,17 +4,16 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api import constants as public_api_constants from apps.public_api.serializers import CustomOnCallShiftSerializer, CustomOnCallShiftUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.schedules.models import CustomOnCallShift from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log from common.api_helpers.filters import ByTeamFilter -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin, UpdateSerializerMixin +from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class CustomOnCallShiftView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSerializerMixin, ModelViewSet): +class CustomOnCallShiftView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) @@ -29,8 +28,6 @@ class CustomOnCallShiftView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSeriali filter_backends = [DjangoFilterBackend] filterset_class = ByTeamFilter - demo_default_id = public_api_constants.DEMO_ON_CALL_SHIFT_ID_1 - def get_queryset(self): name = self.request.query_params.get("name", None) schedule_id = self.request.query_params.get("schedule_id", None) diff --git a/engine/apps/public_api/views/organizations.py b/engine/apps/public_api/views/organizations.py index d3bce01e..f4fd1352 100644 --- a/engine/apps/public_api/views/organizations.py +++ b/engine/apps/public_api/views/organizations.py @@ -3,17 +3,15 @@ from rest_framework.settings import api_settings from rest_framework.viewsets import ReadOnlyModelViewSet from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api import constants as public_api_constants from apps.public_api.serializers import OrganizationSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.user_management.models import Organization -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin +from common.api_helpers.mixins import RateLimitHeadersMixin from common.api_helpers.paginators import TwentyFivePageSizePaginator class OrganizationView( RateLimitHeadersMixin, - DemoTokenMixin, ReadOnlyModelViewSet, ): authentication_classes = (ApiTokenAuthentication,) @@ -26,8 +24,6 @@ class OrganizationView( pagination_class = TwentyFivePageSizePaginator - demo_default_id = public_api_constants.DEMO_ORGANIZATION_ID - def get_queryset(self): # It's a dirty hack to get queryset from the object. Just in case we'll return multiple teams in the future. return Organization.objects.filter(pk=self.request.auth.organization.pk) diff --git a/engine/apps/public_api/views/personal_notifications.py b/engine/apps/public_api/views/personal_notifications.py index 0b3e0b0a..3119bea9 100644 --- a/engine/apps/public_api/views/personal_notifications.py +++ b/engine/apps/public_api/views/personal_notifications.py @@ -6,17 +6,16 @@ from rest_framework.viewsets import ModelViewSet from apps.auth_token.auth import ApiTokenAuthentication from apps.base.models import UserNotificationPolicy -from apps.public_api import constants as public_api_constants from apps.public_api.serializers import PersonalNotificationRuleSerializer, PersonalNotificationRuleUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.user_management.models import User from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log from common.api_helpers.exceptions import BadRequest -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin, UpdateSerializerMixin +from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class PersonalNotificationView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSerializerMixin, ModelViewSet): +class PersonalNotificationView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) @@ -28,8 +27,6 @@ class PersonalNotificationView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSeri pagination_class = FiftyPageSizePaginator - demo_default_id = public_api_constants.DEMO_PERSONAL_NOTIFICATION_ID_1 - def get_queryset(self): user_id = self.request.query_params.get("user_id", None) important = self.request.query_params.get("important", None) diff --git a/engine/apps/public_api/views/resolution_notes.py b/engine/apps/public_api/views/resolution_notes.py index 16e3fa41..7d07ca1f 100644 --- a/engine/apps/public_api/views/resolution_notes.py +++ b/engine/apps/public_api/views/resolution_notes.py @@ -6,14 +6,13 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import ResolutionNote from apps.alerts.tasks import send_update_resolution_note_signal from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api import constants as public_api_constants from apps.public_api.serializers.resolution_notes import ResolutionNoteSerializer, ResolutionNoteUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin, UpdateSerializerMixin +from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class ResolutionNoteView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSerializerMixin, ModelViewSet): +class ResolutionNoteView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) @@ -28,8 +27,6 @@ class ResolutionNoteView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSerializer pagination_class = FiftyPageSizePaginator - demo_default_id = public_api_constants.DEMO_RESOLUTION_NOTE_ID - def get_queryset(self): alert_group_id = self.request.query_params.get("alert_group_id", None) queryset = ResolutionNote.objects.filter( diff --git a/engine/apps/public_api/views/routes.py b/engine/apps/public_api/views/routes.py index a353a962..c7afa492 100644 --- a/engine/apps/public_api/views/routes.py +++ b/engine/apps/public_api/views/routes.py @@ -7,16 +7,15 @@ from rest_framework.viewsets import ModelViewSet from apps.alerts.models import ChannelFilter from apps.auth_token.auth import ApiTokenAuthentication -from apps.public_api import constants as public_api_constants from apps.public_api.serializers import ChannelFilterSerializer, ChannelFilterUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log from common.api_helpers.exceptions import BadRequest -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin, UpdateSerializerMixin +from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import TwentyFivePageSizePaginator -class ChannelFilterView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSerializerMixin, ModelViewSet): +class ChannelFilterView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) @@ -31,8 +30,6 @@ class ChannelFilterView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSerializerM filter_backends = [DjangoFilterBackend] filterset_fields = ["alert_receive_channel"] - demo_default_id = public_api_constants.DEMO_ROUTE_ID_1 - def get_queryset(self): integration_id = self.request.query_params.get("integration_id", None) routing_regex = self.request.query_params.get("routing_regex", None) diff --git a/engine/apps/public_api/views/schedules.py b/engine/apps/public_api/views/schedules.py index 16f6a17a..946463cb 100644 --- a/engine/apps/public_api/views/schedules.py +++ b/engine/apps/public_api/views/schedules.py @@ -7,7 +7,6 @@ from rest_framework.views import Response from rest_framework.viewsets import ModelViewSet from apps.auth_token.auth import ApiTokenAuthentication, ScheduleExportAuthentication -from apps.public_api import constants as public_api_constants from apps.public_api.custom_renderers import CalendarRenderer from apps.public_api.serializers import PolymorphicScheduleSerializer, PolymorphicScheduleUpdateSerializer from apps.public_api.throttlers.user_throttle import UserThrottle @@ -16,11 +15,11 @@ from apps.schedules.models import OnCallSchedule from apps.slack.tasks import update_slack_user_group_for_schedules from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log from common.api_helpers.filters import ByTeamFilter -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin, UpdateSerializerMixin +from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class OnCallScheduleChannelView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSerializerMixin, ModelViewSet): +class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, ModelViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) @@ -32,8 +31,6 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, DemoTokenMixin, UpdateSer pagination_class = FiftyPageSizePaginator - demo_default_id = public_api_constants.DEMO_SCHEDULE_ID_ICAL - filter_backends = (filters.DjangoFilterBackend,) filterset_class = ByTeamFilter diff --git a/engine/apps/public_api/views/slack_channels.py b/engine/apps/public_api/views/slack_channels.py index f261f0b6..14d53247 100644 --- a/engine/apps/public_api/views/slack_channels.py +++ b/engine/apps/public_api/views/slack_channels.py @@ -6,11 +6,11 @@ from apps.auth_token.auth import ApiTokenAuthentication from apps.public_api.serializers.slack_channel import SlackChannelSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.slack.models import SlackChannel -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin +from common.api_helpers.mixins import RateLimitHeadersMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class SlackChannelView(RateLimitHeadersMixin, DemoTokenMixin, mixins.ListModelMixin, GenericViewSet): +class SlackChannelView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) pagination_class = FiftyPageSizePaginator diff --git a/engine/apps/public_api/views/user_groups.py b/engine/apps/public_api/views/user_groups.py index 4e6bbaf3..2859199d 100644 --- a/engine/apps/public_api/views/user_groups.py +++ b/engine/apps/public_api/views/user_groups.py @@ -6,11 +6,11 @@ from apps.auth_token.auth import ApiTokenAuthentication from apps.public_api.serializers.user_groups import UserGroupSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.slack.models import SlackUserGroup -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin +from common.api_helpers.mixins import RateLimitHeadersMixin from common.api_helpers.paginators import FiftyPageSizePaginator -class UserGroupView(RateLimitHeadersMixin, DemoTokenMixin, mixins.ListModelMixin, GenericViewSet): +class UserGroupView(RateLimitHeadersMixin, mixins.ListModelMixin, GenericViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) pagination_class = FiftyPageSizePaginator diff --git a/engine/apps/public_api/views/users.py b/engine/apps/public_api/views/users.py index 99a32a85..54439d6e 100644 --- a/engine/apps/public_api/views/users.py +++ b/engine/apps/public_api/views/users.py @@ -5,19 +5,18 @@ from rest_framework.views import Response from rest_framework.viewsets import ReadOnlyModelViewSet from apps.auth_token.auth import ApiTokenAuthentication, UserScheduleExportAuthentication -from apps.public_api import constants as public_api_constants from apps.public_api.custom_renderers import CalendarRenderer from apps.public_api.serializers import FastUserSerializer, UserSerializer from apps.public_api.throttlers.user_throttle import UserThrottle from apps.schedules.ical_utils import user_ical_export from apps.schedules.models import OnCallSchedule from apps.user_management.models import User -from common.api_helpers.mixins import DemoTokenMixin, RateLimitHeadersMixin, ShortSerializerMixin +from common.api_helpers.mixins import RateLimitHeadersMixin, ShortSerializerMixin from common.api_helpers.paginators import HundredPageSizePaginator from common.constants.role import Role -class UserView(RateLimitHeadersMixin, ShortSerializerMixin, DemoTokenMixin, ReadOnlyModelViewSet): +class UserView(RateLimitHeadersMixin, ShortSerializerMixin, ReadOnlyModelViewSet): authentication_classes = (ApiTokenAuthentication,) permission_classes = (IsAuthenticated,) @@ -29,8 +28,6 @@ class UserView(RateLimitHeadersMixin, ShortSerializerMixin, DemoTokenMixin, Read throttle_classes = [UserThrottle] - demo_default_id = public_api_constants.DEMO_USER_ID - def get_queryset(self): username = self.request.query_params.get("username") email = self.request.query_params.get("email") diff --git a/engine/apps/schedules/tasks/notify_about_empty_shifts_in_schedule.py b/engine/apps/schedules/tasks/notify_about_empty_shifts_in_schedule.py index 5d681bb7..82d96a7e 100644 --- a/engine/apps/schedules/tasks/notify_about_empty_shifts_in_schedule.py +++ b/engine/apps/schedules/tasks/notify_about_empty_shifts_in_schedule.py @@ -4,7 +4,6 @@ from django.apps import apps from django.core.cache import cache from django.utils import timezone -from apps.public_api.constants import DEMO_SCHEDULE_ID_CALENDAR, DEMO_SCHEDULE_ID_ICAL from apps.schedules.ical_utils import list_of_empty_shifts_in_schedule from apps.slack.utils import format_datetime_to_slack, post_message_to_channel from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -19,9 +18,7 @@ def start_check_empty_shifts_in_schedule(): task_logger.info("Start start_notify_about_empty_shifts_in_schedule") - schedules = OnCallSchedule.objects.exclude( - public_primary_key__in=(DEMO_SCHEDULE_ID_CALENDAR, DEMO_SCHEDULE_ID_ICAL) - ) + schedules = OnCallSchedule.objects.all() for schedule in schedules: check_empty_shifts_in_schedule.apply_async((schedule.pk,)) @@ -58,7 +55,7 @@ def start_notify_about_empty_shifts_in_schedule(): schedules = OnCallSchedule.objects.filter( empty_shifts_report_sent_at__lte=week_ago, channel__isnull=False, - ).exclude(public_primary_key__in=(DEMO_SCHEDULE_ID_CALENDAR, DEMO_SCHEDULE_ID_ICAL)) + ) for schedule in schedules: notify_about_empty_shifts_in_schedule.apply_async((schedule.pk,)) diff --git a/engine/apps/schedules/tasks/notify_about_gaps_in_schedule.py b/engine/apps/schedules/tasks/notify_about_gaps_in_schedule.py index 4a4749f6..76d8bfd8 100644 --- a/engine/apps/schedules/tasks/notify_about_gaps_in_schedule.py +++ b/engine/apps/schedules/tasks/notify_about_gaps_in_schedule.py @@ -4,7 +4,6 @@ from django.apps import apps from django.core.cache import cache from django.utils import timezone -from apps.public_api.constants import DEMO_SCHEDULE_ID_CALENDAR, DEMO_SCHEDULE_ID_ICAL from apps.schedules.ical_utils import list_of_gaps_in_schedule from apps.slack.utils import format_datetime_to_slack, post_message_to_channel from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -18,9 +17,7 @@ def start_check_gaps_in_schedule(): task_logger.info("Start start_check_gaps_in_schedule") - schedules = OnCallSchedule.objects.exclude( - public_primary_key__in=(DEMO_SCHEDULE_ID_CALENDAR, DEMO_SCHEDULE_ID_ICAL) - ) + schedules = OnCallSchedule.objects.all() for schedule in schedules: check_gaps_in_schedule.apply_async((schedule.pk,)) @@ -57,7 +54,7 @@ def start_notify_about_gaps_in_schedule(): schedules = OnCallSchedule.objects.filter( gaps_report_sent_at__lte=week_ago, channel__isnull=False, - ).exclude(public_primary_key__in=(DEMO_SCHEDULE_ID_CALENDAR, DEMO_SCHEDULE_ID_ICAL)) + ) for schedule in schedules: notify_about_gaps_in_schedule.apply_async((schedule.pk,)) diff --git a/engine/apps/schedules/tasks/refresh_ical_files.py b/engine/apps/schedules/tasks/refresh_ical_files.py index 083e198f..5e446b8c 100644 --- a/engine/apps/schedules/tasks/refresh_ical_files.py +++ b/engine/apps/schedules/tasks/refresh_ical_files.py @@ -2,7 +2,6 @@ from celery.utils.log import get_task_logger from django.apps import apps from apps.alerts.tasks import notify_ical_schedule_shift -from apps.public_api.constants import DEMO_SCHEDULE_ID_CALENDAR, DEMO_SCHEDULE_ID_ICAL from apps.schedules.ical_utils import is_icals_equal from apps.schedules.tasks import notify_about_empty_shifts_in_schedule, notify_about_gaps_in_schedule from apps.slack.tasks import start_update_slack_user_group_for_schedules @@ -17,9 +16,7 @@ def start_refresh_ical_files(): task_logger.info("Start refresh ical files") - schedules = OnCallSchedule.objects.all().exclude( - public_primary_key__in=(DEMO_SCHEDULE_ID_CALENDAR, DEMO_SCHEDULE_ID_ICAL) - ) + schedules = OnCallSchedule.objects.all() for schedule in schedules: refresh_ical_file.apply_async((schedule.pk,)) diff --git a/engine/apps/slack/tasks.py b/engine/apps/slack/tasks.py index 48c688be..e2c250a0 100644 --- a/engine/apps/slack/tasks.py +++ b/engine/apps/slack/tasks.py @@ -9,8 +9,6 @@ from django.core.cache import cache from django.utils import timezone from apps.alerts.tasks.compare_escalations import compare_escalations -from apps.public_api import constants as public_constants -from apps.public_api.constants import DEMO_SLACK_USER_GROUP_ID from apps.slack.constants import CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME, SLACK_BOT_ID from apps.slack.scenarios.escalation_delivery import EscalationDeliveryStep from apps.slack.scenarios.scenario_step import ScenarioStep @@ -499,7 +497,7 @@ def populate_slack_usergroups(): slack_team_identities = SlackTeamIdentity.objects.filter( detected_token_revoked__isnull=True, - ).exclude(slack_id=public_constants.DEMO_SLACK_TEAM_ID) + ) delay = 0 counter = 0 @@ -642,10 +640,7 @@ def start_update_slack_user_group_for_schedules(): SlackUserGroup = apps.get_model("slack", "SlackUserGroup") user_group_pks = ( - SlackUserGroup.objects.exclude(public_primary_key=DEMO_SLACK_USER_GROUP_ID) - .filter(oncall_schedules__isnull=False) - .distinct() - .values_list("pk", flat=True) + SlackUserGroup.objects.filter(oncall_schedules__isnull=False).distinct().values_list("pk", flat=True) ) for user_group_pk in user_group_pks: @@ -673,7 +668,7 @@ def populate_slack_channels(): slack_team_identities = SlackTeamIdentity.objects.filter( detected_token_revoked__isnull=True, - ).exclude(slack_id=public_constants.DEMO_SLACK_TEAM_ID) + ) delay = 0 counter = 0 diff --git a/engine/common/api_helpers/mixins.py b/engine/common/api_helpers/mixins.py index d121d2fd..503a477e 100644 --- a/engine/common/api_helpers/mixins.py +++ b/engine/common/api_helpers/mixins.py @@ -19,7 +19,6 @@ from apps.alerts.incident_appearance.templaters import ( TemplateLoader, ) from apps.base.messaging import get_messaging_backends -from apps.public_api.helpers import is_demo_token_request from common.api_helpers.exceptions import BadRequest from common.jinja_templater import apply_jinja_template @@ -125,83 +124,6 @@ class EagerLoadingMixin: return queryset -class DemoTokenMixin: - """ - The view mixin for requests to public api with demo token authorization. - """ - - def dispatch(self, request, *args, **kwargs): - """ - Overridden dispatch method of APIView - https://github.com/encode/django-rest-framework/blob/master/rest_framework/views.py#L485 - """ - method = request.method.lower() - - if is_demo_token_request(request) and method in ["post", "put", "delete"]: - self.args = args - self.kwargs = kwargs - request = self.initialize_request(request, *args, **kwargs) - self.request = request - - # there is a strange comment about this - # https://github.com/encode/django-rest-framework/blob/master/rest_framework/views.py#L494 - self.headers = self.default_response_headers - - try: - self.initial(request, *args, **kwargs) - - """ - check for allowed request methods - - from APIView: - If `request.method` does not correspond to a handler method, - determine what kind of exception to raise. - - def http_method_not_allowed(self, request, *args, **kwargs): - raise exceptions.MethodNotAllowed(request.method) - """ - - if method in self.http_method_names: - handler = getattr(self, method, self.http_method_not_allowed) - else: - handler = self.http_method_not_allowed - - # function comparison explanation - # https://stackoverflow.com/a/18217024 - if handler == self.http_method_not_allowed: - response = handler(request, *args, **kwargs) - - elif method == "post": - # It excludes a real instance creation. - # It returns the instance with public primary key - # is equal to demo_default_id - instance = self.model._default_manager.get(public_primary_key=self.demo_default_id) - serializer = self.get_serializer(instance) - headers = self.get_success_headers(serializer.data) - response = Response(data=serializer.data, status=status.HTTP_201_CREATED, headers=headers) - - elif method == "put": - # It excludes a instance update. - # It returns the instance with public primary key - # is equal to demo_default_id - instance = self.get_object() - serializer = self.get_serializer(instance) - headers = self.get_success_headers(serializer.data) - response = Response(data=serializer.data, status=status.HTTP_200_OK, headers=headers) - - elif method == "delete": - # In this case we return nothing just success response. - response = Response(status=status.HTTP_204_NO_CONTENT) - - except Exception as exc: - response = self.handle_exception(exc) - - self.response = self.finalize_response(request, response, *args, **kwargs) - return self.response - - return super().dispatch(request, *args, **kwargs) - - class RateLimitHeadersMixin: # This mixin add RateLimit-Reset header to RateLimited response def handle_exception(self, exc): From 631c2494706c2c540453aa44e7d34f8252d18479 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Thu, 9 Jun 2022 14:48:33 +0200 Subject: [PATCH 058/132] Fix for user ID cloud endpoint (#32) * Fix for user ID cloud endpoint --- .../src/containers/UserSettings/parts/index.tsx | 2 +- .../tabs/CloudPhoneSettings/CloudPhoneSettings.tsx | 11 +++++++---- grafana-plugin/src/models/cloud/cloud.ts | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/grafana-plugin/src/containers/UserSettings/parts/index.tsx b/grafana-plugin/src/containers/UserSettings/parts/index.tsx index 7cbb0f4b..82e57fd6 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/index.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/index.tsx @@ -132,7 +132,7 @@ export const TabsContent = observer((props: TabsContentProps) => { {activeTab === UserSettingsTab.NotificationSettings && } {activeTab === UserSettingsTab.PhoneVerification && (store.hasFeature(AppFeature.CloudNotifications) ? ( - + ) : ( ))} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index 4a9d6f20..842869f7 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -21,7 +21,7 @@ import GTable from 'components/GTable/GTable'; import PluginLink from 'components/PluginLink/PluginLink'; import Text from 'components/Text/Text'; import WithConfirm from 'components/WithConfirm/WithConfirm'; -import { User as UserType } from 'models/user/user.types'; +import { User } from 'models/user/user.types'; import { AppFeature } from 'state/features'; import { WithStoreProps } from 'state/types'; import { useStore } from 'state/useStore'; @@ -32,9 +32,12 @@ import styles from './CloudPhoneSettings.module.css'; const cx = cn.bind(styles); -interface CloudPhoneSettingsProps extends WithStoreProps {} +interface CloudPhoneSettingsProps extends WithStoreProps { + userPk?: User['pk']; +} const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { + const { userPk } = props; const store = useStore(); const [syncing, setSyncing] = useState(false); const [userStatus, setUserStatus] = useState(0); @@ -50,12 +53,12 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { const syncUser = async () => { setSyncing(true); - await store.cloudStore.syncCloudUser(store.userStore.currentUserPk); + await store.cloudStore.syncCloudUser(userPk); setSyncing(false); }; const getCloudUserInfo = async () => { - const cloudUser = await store.cloudStore.getCloudUser(store.userStore.currentUserPk); + const cloudUser = await store.cloudStore.getCloudUser(userPk); setUserStatus(cloudUser?.cloud_data?.status); setUserLink(cloudUser?.cloud_data?.link); }; diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts index f93c1d46..d917075b 100644 --- a/grafana-plugin/src/models/cloud/cloud.ts +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -59,7 +59,7 @@ export class CloudStore extends BaseStore { } async syncCloudUser(id: string) { - return await makeRequest(`${this.path}`, { method: 'POST' }); + return await makeRequest(`${this.path}${id}/sync/`, { method: 'POST' }); } async getCloudUser(id: string) { From bd923936575bed2a92cfbc5b7e348d9ca4ce1e3d Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 15:49:14 +0300 Subject: [PATCH 059/132] Updating a lot... --- README.md | 54 ++++++++------------ deploy/docker-compose/README.md | 28 ---------- deploy/docker-compose/docker-compose.yml | 32 +++--------- docs/sources/integrations/webhooks/_index.md | 2 +- 4 files changed, 28 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index aa8300dc..e89ee533 100644 --- a/README.md +++ b/README.md @@ -11,57 +11,45 @@ Developer-friendly, incident response management with brilliant Slack integratio ![Grafana OnCall Screenshot](screenshot.png) ## Getting Started -OnCall consists of two parts: -1. OnCall backend -2. "Grafana OnCall" plugin you need to install in your Grafana -### How to run OnCall backend -1. An all-in-one image of OnCall is available on docker hub to run it: +### Launch "hobby" environment + +Download docker-compose.yaml: ```bash -docker run -it --name oncall-backend -p 8000:8000 grafana/oncall-all-in-one +curl https://github.com/... -o docker-compose.yaml ``` -2. When the image starts up you will see a message like this: +Set environment: ```bash -👋 This script will issue an invite token to securely connect the frontend. -Maintainers will be happy to help in the slack channel #grafana-oncall: https://slack.grafana.com/ -Your invite token: , use it in the Grafana OnCall plugin. +export DOMAIN=http://localhost +export SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long +export RABBITMQ_PASSWORD=rabbitmq_secret_pw +export MYSQL_PASSWORD=mysql_secret_pw +export COMPOSE_PROFILES=with_grafana +export GRAFANA_USER=admin +export GRAFANA_PASSWORD=admin ``` -3. If you started your container detached with -d check the log: +Launch stack: ```bash -docker logs oncall-backend +docker-compose -f docker-compose.yml up --build -d ``` -### How to install "Grafana OnCall" Plugin and connect with a backend -1. Open Grafana in your browser and login as an Admin -2. Navigate to Configuration → Plugins -3. Type Grafana OnCall into the "Search Grafana plugins" field -4. Select the Grafana OnCall plugin and press the "Install" button -5. On the Grafana OnCall Plugin page Enable the plugin and go to the Configuration tab you should see a status field with the message -``` -OnCall has not been setup, configure & initialize below. -``` -6. Fill in configuration fields using the token you got from the backend earlier, then press "Install Configuration" -``` -OnCall API URL: (The URL & port used to access OnCall) -http://host.docker.internal:8000 - -OnCall Invitation Token (Single use token to connect Grafana instance): -Invitation token from docker startup - -Grafana URL (URL OnCall will use to talk to this Grafana instance): -http://localhost:3000 (or http://host.docker.internal:3000 if your grafana is running in Docker locally) +Get the instructions and the token: +```bash +docker-compose -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override ``` -## Getting Help +^ follow instructions and enjoy! + +## Join our comminuty - `#grafana-oncall` channel at https://slack.grafana.com/ - Grafana Labs community forum for OnCall: https://community.grafana.com - File an [issue](https://github.com/grafana/oncall/issues) for bugs, issues and feature suggestions. ## Production Setup -Looking for the production instructions? We're going to release them soon. Please join our Slack channel to be the first to know about them. +For production setup check [PRODUCTION.md](PRODUCTION.md). ## Further Reading - *Documentation* - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/) diff --git a/deploy/docker-compose/README.md b/deploy/docker-compose/README.md index 41e77bd8..e69de29b 100644 --- a/deploy/docker-compose/README.md +++ b/deploy/docker-compose/README.md @@ -1,28 +0,0 @@ -Download docker-compose.yaml -```bash -curl https://github.com/... -o docker-compose.yaml -``` - -Start docker-compose stack -```bash -DOMAIN=localhost \ -SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long \ -RABBITMQ_PASSWORD=rabbitmq_secret_pw \ -MYSQL_PASSWORD=mysql_secret_pw \ -COMPOSE_PROFILES=with_grafana \ -GRAFANA_USER=admin \ -GRAFANA_PASSWORD=grafana_secret_pw \ -docker-compose -f docker-compose.yml up --build -d -``` - -Get the instructions and credentials -```bash -DOMAIN=localhost \ -SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long \ -RABBITMQ_PASSWORD=rabbitmq_secret_pw \ -MYSQL_PASSWORD=mysql_secret_pw \ -COMPOSE_PROFILES=with_grafana \ -GRAFANA_USER=admin \ -GRAFANA_PASSWORD=grafana_secret_pw \ -docker-compose -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override -``` diff --git a/deploy/docker-compose/docker-compose.yml b/deploy/docker-compose/docker-compose.yml index 7f7d998c..a07bb72e 100644 --- a/deploy/docker-compose/docker-compose.yml +++ b/deploy/docker-compose/docker-compose.yml @@ -3,6 +3,8 @@ services: # TODO: change to the public image once it's public # image: ... build: ../../engine + ports: + - 8080:8080 command: > sh -c "uwsgi --ini uwsgi.ini" environment: @@ -49,7 +51,7 @@ services: MYSQL_PORT: 3306 REDIS_URI: redis://redis:6379/0 DJANGO_SETTINGS_MODULE: settings.hobby - CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook" + CELERY_WORKER_QUEUE: "celery" CELERY_WORKER_CONCURRENCY: "1" CELERY_WORKER_MAX_TASKS_PER_CHILD: "100" CELERY_WORKER_SHUTDOWN_INTERVAL: "65m" @@ -138,6 +140,8 @@ services: grafana: image: "grafana/grafana:8.3.2" mem_limit: 500m + ports: + - 3000:3000 cpus: 0.5 environment: GF_DATABASE_TYPE: mysql @@ -148,8 +152,6 @@ services: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:?err} GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app GF_INSTALL_PLUGINS: grafana-oncall-app - GF_SERVER_ROOT_URL: http://$DOMAIN/grafana/ - GF_SERVER_SERVE_FROM_SUB_PATH: "true" volumes: - ../../grafana-plugin:/var/lib/grafana/plugins/grafana-plugin depends_on: @@ -160,30 +162,8 @@ services: profiles: - with_grafana - caddy: - image: caddy - volumes: - - caddy_data:/data - - caddy_config:/config - ports: - - 80:80 - - 443:443 - command: - - sh - - '-c' - - | - cat < /etc/caddy/Caddyfile - {\$$CADDY_DOMAIN} { - reverse_proxy /grafana/* grafana:3000 - reverse_proxy /* engine:8080 - } - EOF - caddy run --config=/etc/caddy/Caddyfile - environment: - CADDY_DOMAIN: $DOMAIN - volumes: dbdata: rabbitmqdata: caddy_data: - caddy_config: \ No newline at end of file + caddy_config: diff --git a/docs/sources/integrations/webhooks/_index.md b/docs/sources/integrations/webhooks/_index.md index 026458a8..c5a92ffe 100644 --- a/docs/sources/integrations/webhooks/_index.md +++ b/docs/sources/integrations/webhooks/_index.md @@ -9,4 +9,4 @@ You can use webhooks to send alert group notifications, and also to receive aler Follow these links to learn more about using webhooks for OnCall alert notifications: -{{< section >}} \ No newline at end of file +{{< section >}} From f74e9565bcc2dd16b76d492e9ee6c2a9ee1d627c Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 15:58:10 +0300 Subject: [PATCH 060/132] Update README.md --- README.md | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index e89ee533..b3b229e9 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ -# Grafana OnCall Incident Response -Grafana OnCall, cloud version of Grafana OnCall: https://grafana.com/products/cloud/ +# Grafana OnCall Developer-friendly, incident response management with brilliant Slack integration. -- Connect monitoring systems -- Collect and analyze data -- On-call rotation -- Automatic escalation -- Never miss alerts with calls and SMS +- Collect and analyze alerts from multiple monitoring systems +- On-call rotations based on schedules +- Automatic escalations +- Phone calls, SMS, Slack, Telegram notifications ![Grafana OnCall Screenshot](screenshot.png) ## Getting Started -### Launch "hobby" environment +### Production environment + +For production setup check [PRODUCTION.md](PRODUCTION.md). + +### Hobby environment Download docker-compose.yaml: ```bash @@ -30,35 +32,22 @@ export GRAFANA_USER=admin export GRAFANA_PASSWORD=admin ``` -Launch stack: +Launch services: ```bash docker-compose -f docker-compose.yml up --build -d ``` -Get the instructions and the token: +Issue invite token and get further instructions: ```bash docker-compose -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override ``` -^ follow instructions and enjoy! - ## Join our comminuty - `#grafana-oncall` channel at https://slack.grafana.com/ - Grafana Labs community forum for OnCall: https://community.grafana.com - File an [issue](https://github.com/grafana/oncall/issues) for bugs, issues and feature suggestions. -## Production Setup - -For production setup check [PRODUCTION.md](PRODUCTION.md). - ## Further Reading - *Documentation* - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/) - *Blog Post* - [Announcing Grafana OnCall, the easiest way to do on-call management](https://grafana.com/blog/2021/11/09/announcing-grafana-oncall/) - *Presentation* - [Deep dive into the Grafana, Prometheus, and Alertmanager stack for alerting and on-call management](https://grafana.com/go/observabilitycon/2021/alerting/?pg=blog) - -## FAQ - -- How do I generate a new invitation token to connect plugin with a backend? -```bash -docker exec oncall-backend python manage.py issue_invite_for_the_frontend --override -``` From ae52729f0f23644d41b8c6b3ac255b77a86bbcbb Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 16:02:38 +0300 Subject: [PATCH 061/132] Update README.md --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b3b229e9..bf5d9a18 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,8 @@ Issue invite token and get further instructions: docker-compose -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override ``` -## Join our comminuty -- `#grafana-oncall` channel at https://slack.grafana.com/ -- Grafana Labs community forum for OnCall: https://community.grafana.com -- File an [issue](https://github.com/grafana/oncall/issues) for bugs, issues and feature suggestions. +## Join our comminuty 👋 + ## Further Reading - *Documentation* - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/) From 17def87fab1571610c00af85d3a3b743944b20c6 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 16:06:21 +0300 Subject: [PATCH 062/132] Readme --- README.md | 6 ++++-- docs/img/GH_discussions.png | Bin 0 -> 3742 bytes docs/img/community_call.png | Bin 0 -> 4468 bytes 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 docs/img/GH_discussions.png create mode 100644 docs/img/community_call.png diff --git a/README.md b/README.md index bf5d9a18..4a01522d 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,10 @@ Issue invite token and get further instructions: docker-compose -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override ``` -## Join our comminuty 👋 - +## Join our community 👋 +| | | +|-------------|-------------| +| ![](docs/img/community_call.png) | ![](docs/img/GH_discussions.png) | ## Further Reading - *Documentation* - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/) diff --git a/docs/img/GH_discussions.png b/docs/img/GH_discussions.png new file mode 100644 index 0000000000000000000000000000000000000000..9d38a610d0c1228416483de60e1bf36835bef4ab GIT binary patch literal 3742 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 ac1bk=O@^TbPMEoDNb2Rt> literal 0 HcmV?d00001 diff --git a/docs/img/community_call.png b/docs/img/community_call.png new file mode 100644 index 0000000000000000000000000000000000000000..32b6a3a3470264dec9ce054c720b8aa8efec5017 GIT binary patch literal 4468 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 Ut?GLBNka5mRa>P}$ui{s08!eT(*OVf literal 0 HcmV?d00001 From 5cf63637b69d4971d73ca8a7203dc4035ced1447 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 16:18:18 +0300 Subject: [PATCH 063/132] New images --- docs/img/GH_discussions.png | Bin 3742 -> 9780 bytes docs/img/community_call.png | Bin 4468 -> 13035 bytes docs/img/slack.png | Bin 0 -> 9704 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/img/slack.png diff --git a/docs/img/GH_discussions.png b/docs/img/GH_discussions.png index 9d38a610d0c1228416483de60e1bf36835bef4ab..d3a1798a0e477b29cc8d512bc09f1c61903cfb88 100644 GIT binary patch literal 9780 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ౡ(I<@*fzM~D8k7<468F#`c*kb+U z#Ew(;9qs7lTl>I?ba9MhQF*kYq-j51_ix3X&W*If_0>JBWNa*52mBW;UBUbf94KJi z)wvz@%Lg?$dTtLzm3yW93dq{+i$=REP!K0ro+&iZ6rMOD(9jdkK@#ms~wUcm= zMV20DHt#63m?D40MxT>Iw;#Ry8Y?!S_hAG0Cq+rmsBGsj3XY zwDkLvI$SHNXZ+UQt>SYsM24MguDTcsqB%DGAxCXM#CIr3KzO0xL`(QB38RzdRo=mc zhyQW&t|TN0nAf_(24qW1W42C6&XmC5NTVO4f0&9z-V!aKORb{uz415dPV=pE38~VN ze_ZpP&7N5lFQhZ&KcQP{FNw7|+gc9}aN>h#($A@vu%HA@Xw6vzCT8iD8#uhH2qeq) zA=3wFO^na1R7sPV|IUd>X&*~fjn6yb?ehYxt$lXwNZK$_cu|!S_~wt_Df&7=Ivj5^ zuJzP$g`wx1kd3s#kCvh@R}F8IWUd-WvOjq`J}O0Kj_N+RD1W?#yon3xmV@}i>2ULnc zZ-~;7Gtc9E0?NBwLPMfd(2GMW$1*(bmFAGIwyZewR?{u9H;a z0fqcyIkJKEJ83yOdV^vymR)?KWS2TIZfwLJ>+TyFg|jLxQ_MAQz?-jrc=o2W$BADAZiB1Z)`@-qsSfD>Z;oqa$c$wR_#}bSOeke+j{1FoxXsksW;ICx=GTYmP~hggExcTYF&vr16GSFZ&g|&VuJGU1sSX3w9WSt(2Y6eeaA6@&3| z`K1GAb5Gs7UT-{-);yNZ;8Y0{BJ-Zt=d-ghXNLMgDXJ$wNcfRi%b_CK-n3M-jeX;V3@sC_ zZr(dg21pDO+nRC%BRS6`Hv8LK6_?;NkxoF4IH!Ed?IsC(-%Ox^*Kw^?k@08!32$MV z#VQw}qwkQo^K3o_^H*haU1n6HBC#F5WxQ55;*4Np3(O<1)a%{!>+^T#gTiiFWH+8T zFI#5H!Fk36t(2eXVC8#@o#ISm)DpM^=|r>g8S1y+dw2v;8F0p78rej}14h8$KLZ0a zNe&3(liECLmkJBRl}bZ27>jIb|6Ctl{%BtAL;t^^cr5Q=JA^zC2>kh>$2-Kb%X?A8 zHUawCa=(pI7e+((ac97==UIuKCwmnhMBK z+z!8o)cK=3{z1%}pe!17F1K(@vau4u0TUE$pwdezQC6&|Rw8xZwRWrQ# zCDA^A^X+73im3aj*Fq=39nIIv=cPWn*pvI|&F-ADL?D$f4oy0gk@&%(%R+UQ^Onh0 zK^q>*rk22C@K@q&h~LN9Q~7q=(fypwrnOfpcjk0VKuhN{2>y}wS88X%@OE?!r)_?e zMMP5$(a~ya>kJgbDEfJ&s; zDSQ%uVlhRC>~bl#vE&Xdb48w7YDoX9FhN{+5@z{-OU2^dN+z8p8V{M=jS43dt!vcJM31xc4)y!aDZAMgM9QCou6SLP~Y#<b%7 literal 3742 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 ac1bk=O@^TbPMEoDNb2Rt> diff --git a/docs/img/community_call.png b/docs/img/community_call.png index 32b6a3a3470264dec9ce054c720b8aa8efec5017..22692fade5e35b84af93a154c21e8360af69bfda 100644 GIT binary patch literal 13035 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 Ut?GLBNka5mRa>P}$ui{s08!eT(*OVf diff --git a/docs/img/slack.png b/docs/img/slack.png new file mode 100644 index 0000000000000000000000000000000000000000..d5ec5e6da150b7f9c3eb80c3f7d30e0b5e8d0627 GIT binary patch literal 9704 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 literal 0 HcmV?d00001 From cd8b7e44659914e20a9571d9d7f28721b45f6a59 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 16:19:06 +0300 Subject: [PATCH 064/132] Update README.md --- README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4a01522d..ac592477 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,16 @@ docker-compose -f docker-compose.yml run engine python manage.py issue_invite_fo ``` ## Join our community 👋 -| | | -|-------------|-------------| -| ![](docs/img/community_call.png) | ![](docs/img/GH_discussions.png) | + + + + + + + + + +
## Further Reading - *Documentation* - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/) From 37856427c3b5fb290d2c51448019576897f8ed0e Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 16:27:45 +0300 Subject: [PATCH 065/132] Update README.md --- README.md | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ac592477..73f53f90 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ Developer-friendly, incident response management with brilliant Slack integratio - Automatic escalations - Phone calls, SMS, Slack, Telegram notifications + + + + ![Grafana OnCall Screenshot](screenshot.png) ## Getting Started @@ -42,18 +46,6 @@ Issue invite token and get further instructions: docker-compose -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override ``` -## Join our community 👋 - - - - - - - - - -
- ## Further Reading - *Documentation* - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/) - *Blog Post* - [Announcing Grafana OnCall, the easiest way to do on-call management](https://grafana.com/blog/2021/11/09/announcing-grafana-oncall/) From e8f176d660610da8abbbf792798a0f8c6b523e9a Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 16:29:14 +0300 Subject: [PATCH 066/132] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 73f53f90..4e2782c7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # Grafana OnCall Developer-friendly, incident response management with brilliant Slack integration. + + + - Collect and analyze alerts from multiple monitoring systems - On-call rotations based on schedules - Automatic escalations @@ -10,8 +13,6 @@ Developer-friendly, incident response management with brilliant Slack integratio -![Grafana OnCall Screenshot](screenshot.png) - ## Getting Started ### Production environment From 20e68752b18eae5c195f6c96ec71ac621d0e648d Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 16:29:43 +0300 Subject: [PATCH 067/132] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e2782c7..d37ca43c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Developer-friendly, incident response management with brilliant Slack integration. - + - Collect and analyze alerts from multiple monitoring systems - On-call rotations based on schedules From 04d1de939e23cf2f4bf0b3edbc5a48803032982f Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 16:33:27 +0300 Subject: [PATCH 068/132] Logo --- docs/img/logo.png | Bin 0 -> 50860 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/img/logo.png diff --git a/docs/img/logo.png b/docs/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ec11fc3a4a0f52d598af8b176327a6b1e31cae3d GIT binary patch literal 50860 zcmZ5{1#nx-vaK0nX2;CT%oICjjG394nVH#6%yiZ_YU`WzZ;woTZ(9s|7m*8MNUM12OS3WNAj#64KU|`sk z|2)B^RVXe$z65tskrV@~o*+2-_yA=gDlZBK21r17HG&2M8x)lm7ghBHKYcsN!q;-& zh^lT(GEG`{lm6AZw@`$je0J{gItGg~8qkd@eX{zb#9%NeIio3e9Ls^1z>s`x#t>&q zMJaooWSeBGlTIl+UTiCS4cIgG`>hB6jN%BGpia=X4(1b@wEW7*s-?DG$%GCtH&|Hl zfoAgpM+JlEepaIdhk`)kcejNi+jtvnLyK*30oi)oER|iRB^(Xo2ingn9`gmJYzvYd zLL}#fXIY(JN@d}0LU?sQ#s??_KD@p{VQskhi%HB2JzHS`Ly&vdw^7dt3y$AoK+hhV zuzf6u|9ZrJ5n(a^iW<7T1o$@9^G!)j&-Jl2Z*8wDG{euvC&+*tz(SDcGZoW^X>qBj z=gB{rH)qnDPrSIVHYz{64k>&>Sa}$aQw|${II$B!+!X*^FeLu>|0DKk>d4X9C%$aH z415d37c4%W^qzJfjj3DjQ{5st{c5%7CEE0eo%4rfYvV(XZYF4S7e9` z05^F3#Nt9pOm8Ng;5a0VI3?Nz;S1{TdS&+eYZ{0qb}R*+pM+j340!aE4^lG>CcVS% zQ#FiDbj5G)j7K62(QxWWmq1x-z0!E`H&8j5kwui^a4?kD=IpGIHav{*$uYPM!(+Ym zmVcIw7-pQc6tyAEG}v%xJyVDuH9X+f_(c+4sQqu~J-Yw8actT^D3CQ$8J(Gs_vF`v z6?11UbU9a1B|e9GA_E+n1^8$K6qXzIar_1YtxIfwP(pAe^=pwpkA&RB=RsTfuh=>D z-g8NvpW8Y!f|H<9|mz(T2Qc6q}JJr_GDV}%>x{@(+jovHMS7|gTg^>mn}&mgFQ z*1kMV~k~(a%^&OKT=DaIyq6N4QNmp(T~* zp0cRVmM|py5eU=oftOFlIL3s}^IyhEHpy{t{o8f1dqbf>YDZ8PNDQUGW!a{r24g1b z^e{|03fpuAT=~A+hoN&}eMoHry&AqXGGF4rl4@{zFP-hDo3x{oJCub93&JFjLSeFG zct9^(B^8Bv@zDJ#z~LEs-TR6Az3G1qS<)YBJn(k$=Agj)@F*|h(X%)fMX;Q><2y>? z906a(YsAy$tfs_LdpM%L{i6>@T!1REXJ1M}T);|yM!}4s8u zNVKnrea^+NekLn7LPO2z;CSz*FfvV(zzL1s@ArF)fXF%=5S7SH=pg7!RZD62IM9W* zTFe1V^$avQOtzuedQJRL$a|FD0N zIYe)>9>A8X46MWZu>su;Y)4|ph=*%|C1`T;M)nB&;zDoQv8*UZMwY;nB+4H*59g-5 zIzD2D<~84t%E4Nv-#B(HP-aql=sWefN^jaLM>EtppTQA@U@9Jd@UL4>+b<~4batIV z8}oRB(;;4=XWhX?(Kuqmt6KHm!NH&1?tvf7fjK$VsYDcAYxh#ltmcpZN#(Tc*cT|* zo30Th3;$_3!WmJQ8kYga8_jb-UbM2Jn@-~mnR>S;dO&+yc2V1DqQ@38{=!1rdNR`LBD=&`=Nn-DN;Jr zR`N*&8;+ssZ|L%b19weq7Skq&@6-p+!Oj=a^%<$PV@6$~npGkhK|=}jCdVaa(PvAb z1I=xPu;NoZrfS0~f*T7h@rA45y2j$6xs<&>B<>^emB*|$hb?0gF2YLGr^=lNpmjg9 z2alS-ziokFQ4$7(T?KhCqt3=}4r^EsE&vf13LT|0Xi{D|A}AAaoG%?eM^^KMLu!t- zkdA`?G8Rn7TC8L0ccSmP>RGC(LoF13#QC)?U&*(V=Ju4bB!Oe)@LbI(5L`=AzbXPJ{nvASRSyz?W5nLta;nx7lyxy_L>Wb&fh5 zY79TS5;s#&WZ&(T6SZzllNLpZlB##LXhvD3XT%vV3Oj{(YESDXeneIfOdEBmt#|8) zP=;Gz@xpg+Yn99}doDMMUR@iyFH`GoGSO*aNR?l|8js7os?tar z3N*GyT)--yIS`!*Z1BFHcw7YiZ4nm}v(OLM7_@^Sd%Vt$ui*bl)d7-PgSV!_`qHN) zV-pOFWlqZSB|}7yas^T9ZFf5d&FlA)<%{`23F0!^{D4+V3{6!Qw*=d>^~bZ8FE`a~ ztkz{x^|tpis4;L57C~DuDyD%=Jw}UQ>)tj?Y<$5wgKW*w@P7+vL;fFggq7e#7scR4 zaJO$PrC1ed+pc3~nFOVL)p}Y~2%QRfu3)3cNd1IuFG?v}u2oz4r6t)d{>suz^A8Nx z#TMAlXp=6pzN>=06zEdN$m4E-;Z}C(d*}GnDR=_%QT1wrv+D2_7rfJ*suKDNj_ zqN{kF*eAA>jYYMju9E{YJpRit=t?&>m?2NW{mCUQbq0)u~l7?hj;J z(IKjoPt}Ci5^={o*h7UyNMV2-$iWvXlxtD4?K_&jx`y*&Y(GV(kSm_w$-LdNh-~Ey zzDA14$}k}{)e*5URI;5dBJp{Nv0ZT) zj{n$ouO&@UJ=m}Q@-wyZbYdTK1mxyqxt zYPC`bre?3Ga<=OIIf!!r!@NpKiM~O%KKVua=as;vSlsvb#X&3d;YLmEao*#BK}%87 zP6+WU@Hc46tn!fJC>v3$3~r!3PKIyX=9eAmf{=kesnBJ@;x8QG7JuN1p+Uy>JLobj zH)Mhq)Nn9xGW-6ervKF>S;SbsSvzTjUct;b4FBs+nkdW^fBt-#=zDR0D!rhP&j=8ycZr>V5p7Az|VD)ML77qL*-uQDgk_gf~RTN^e z-9Wd4#RbEWZMf~6L>j3sxu4}`m&XwEF5%Krvl}q)EdyL&h-*8kh`XK5`!^Mvg+;AC zG(?=-H_I}PC}te735SE}g{XjgUxl><|I04kyGWsXAy%nQaX|oXa(PSlLIbDoKK^db@&~-|}{c;d- zvyhc4kJ0JzvK+s$*(gY_m9tK&!d1zO+-6MTMNSCFL8B9y+{{VQ3ePOm$$q@#*J*bH z@t9O}QNr^{RT&dKpulgMp>!nBF~Sa#MA8gO*==V`w(H*DnFaiT*`1qzljoUA?K0h9 zE2|Q&tghT`sE$UG1=GhP$QtJBNaG!L1}mQDw|zyzJ6R9!iegF{e2bi zC|0wxM#LXZ`L=y|he7KD&`>f_{X(gQXQm(gKH7Dk#%LKxg4eg00F`m&?uut|;XGBL zrws~Z{D4(PWEvr}H?{7-=78Pi{2p4-pBUSX)Uy z{6OwzBE^D1f?Gn!hMSR^w+*cSi3-Nrw3x846)(?875aJtI4NYgd+KV{stn}agtb^@ z4{4}~(ois|sJez(48~6!cWB(z?YEOuF^-=4?Mkn?0X$fvp|E7pF3UR@M3nXvlpzCq z#TCf(d@jI6{~8o_islafNSs$ZpOz8!V%ht(_#HNq0{<$Pgn1vbV@O?}J&i7B_`j)W zX5<3=Ko1~#ey6E}!BeJxaJN!zxxF6+_1EY(BAyHw$rrV*!9==Owt^u$bz>H zEt?Pay1$WzkQ@h#;>nROg@K~MqWZCok*N%omG{R*?1?ugl-KE>W#2Q}b)9ze zrRmD63yJ&2-=xWXt{xDJFuW=yN;mijmm2E(3u!&03_Rwby|N*$#Lv)MV6VL10~FDw z_dh;n(B7fJPFg|;T<0l3^j5$24%+NNw@fTGjH247fER?*9_MHH1Fd?4b>UNTEjtew zS~O1*eq}fZUE+8X27Aa7IW4cSdE)O!0=21JFefNyH6)lY?hM}3Hdlk8Q6DG~J$$7n z_8`Cs)|fRXIlm%vF)KK(|Kc%{#AD)?mStADfxn6yF5pD zSH47KDc!ENmeYul)~bwctPn60n4@6mM)9LcXM%p((Wm3?=zQ_@wMyy*h7i94*GUq| zQEs{D%0J!lfu#w8YdtDlTm?FX;ut)o)#ye*CqeBDExyijn`ztOTSGYcwdFR*{Z5aW zZB_859=pqjtbPF2yq>=p{+_^Tz={r$T3gQdc$<~qqnYPBN+BpmQ`z~^W2B&3^66mjY5sXp z_-=$5w85!K!fFq3bn#TRJhRF$Ilv-oCep=*s+-*nKTbw8)DOHCqOAwyHS<@7YhV)& zU2fI*84cmE!R^4VPs$NxqNFX=Y@~=@Dv8lH`%yGDb8YBlHVFI1>QG_-RY45SYJHUC z)1b!ypAP5w+{If35hlcwh(E7cXT?vn*bz?aq`Ljnui3MajSGj=Yk2B0QQzTK`A?Nl zBMgYqZ`)UvdngN(Yxy55$Fk&`)sRabxChFZ_?&UwSk$lfihFkTyXu>qF;f`9IaSSau%;4G$$ zAha2NDHfxj_S2o1#8GPd>E~TV&Pc64nlv{@bKxNguA9P7K^Xy04-P%q%>jTm_|3JY zbJVw{UB}T3Qx^^`<6GXt#T5nmbEEG}ykpW>3bOMP+LW~4^xTG8C2{WGLj6(WBRJXx z6~W*7y6(e9v8Iu!0-&PtxtNn< zs{2|I==81|V}%2e%{PaMz*z4(wwJi@9(e~mEX~d0GcGbfm_SHK7zOQi8}z#JM=`wl zj35^|30;L~*a$x-8FTMI<`w&jgOOXHrY_>gACuLnGxd(2u$_It zNviU5)wDMF+s6fgBc-BpYiX_Y35<-;Tc(INox zKkr<0z4tQAvm^+z`_5D15JKl%0y}{5``0&I3of2%xFZ+5RVf*++hlm7?y>}7D)E5d z_-Kpv=Z|l$XOR`pLjgzUf|;Z!{<{eL{_7cT%c;1?t=g%w zxYm$B(qCNPj`3qui0RCf)aKKp2L2)p$`tod>hmq^hdAE{;ub?s|t%q z#LhramAp%XJnNT);l!VDz78>+-vd#jPS`SX1MT9JcpCHnX#jwBAD&sr&q2d^LaU-4 z^EDL}1BO_VmE!j60Hqpw(PKdrf0{cZTzlO^Bam<{^>)=>%zWy}wym{WtE)1;Am&4okD9%$Z=M zGhv1&lAr^crxxjuw;u&SBN*5Ex9yX_qQxskjpE+t1GYKBp+;}TZ|%d!o10!w8U7%u zvB0VCQ=f%aiaxb!YDA{}W?Y3*T9KwTev^-S30y(+pUJV_xmsXYiTsPlGmyKWMGL>4 zc_jXQ4@{VzUo$e~7Z6FKOXCo&mJ)Xe!d zk?-L*@`Z3ghhX!MzQ~*~c)@##M)-X_Kv`7zVRIKu!nD)AyJB1)s>dv;SHz6MhYLKf z?akx6QVa;d!J}HZo6@7|Mes@+@v3S=#w16!Z9F>_kI3IWsk98%WA@DZpAN7&GiJae zxk0m>6u+07Y8*0K=?+EU*G3Wd;f4MY^n%13K>Q&v2DDqTSRlG2>f3|E9hpu|l0o`8 zUlQFPW%pnqqx4QUkj)U^TGEZ^)PC+e18G}P$PD2$M-^ncZhGo>r<>rb2;pn5O?$_K7IP|2CX(`2RW~Jk&ZPQS`h5DD*OVH4@PDVw zK1~;MI?S>)EzdUbu{xzn$|E6V#3b5nMU&)mkZFHaK~996S?XaEeNY;(wowwy6&DN#a~rB zNXT;7us^@F$8ISsW{*+G{KU;piOm^U?&AP{fryd(Gn%$IfJJX_cBX=D!5qn{SykW8 zdn~X(@WC?7y4l3CoxkoBz>i9#nv1UVjBj&S&{N}W5}$8Ak%>z-%}H88Bs=T5hpuQ4 z6eV#j4gk)|rM9eI9wPD(H*0&&{ypAUH*+d6r&!zk37_~ainib+SB1Vv1a-nZ=@{gbw|gsMf#C8LEUWY^=QstBMc6>ga5ghvA?^ z_rPLWE>u7&hy&}&3&&@r?2Nxl)hR*`TB6_VXl*n9m5?vrfR?{}Wt$Bpy!zRNt2yW2D-BT1lL;>Bc!wY)P3zKR zndDZG7T2Q1iOHvdzUn|gas=l2?@gda==dLnsG4`FiwlC0 zq_=iY{bFq*SC2nAlNN%hBdoD|G6PCUIL2Tz-fwx!EpcKHM67AsUY-*0+68D0Zd5ne&q0+fjte+)wdWyJ=#Ca$z zw6y$fRGb(~cP_7K>TBVKl#b(tbtzPN#sIq1{7&=f){O4O$kzu?9eresGw3z#bBS#{ zaFc}2KK?D#C4xuZdo#Or2Sm}{umhHYKY3z~f%FD=9DF2&e>ni}*1MxV@|;dC2&bU> z;pIXd>^x4jqVsas|+l4ar{31zFgZ!H>ydr2wW`mX1AOFPgu4Sci^Mhi!Zbvd%H zZwpRUhnbrkJ<5RV8`6j1Equ_9htkK zicnq#y{vUCNt@5m8l9;-0!={NbCaRESDCudc*1a-z7^iELExC}+voMlqlT0G-2HMB z2u0*bNuD{{eA5#)=|H@kzi}17kpn5(yJ`{B$1vnmw7zgd>D zj%%To%>jdD4p&zxSt->x?lq!TVgECnql6(=9~%WJ7k-5qzEiP3YU;h#akvfrr`c4M z1ZAO&gD~6c@x7YGx?U{h%(-mdk)U67pERwFc zJb%R?;x>v6d0bfd3}Rlts+vR?d=wIyf53pz7wLrjH;x%gSPLHeh)=@dBJ4f&*UC=a z&Bj$R2X}wW)9Gu&9CJXv2}JjExUGp3)FN-Og)sm%1ApxcG9v4Z-Rqs53WAyS@KlVc z+?Kb0CFOP?*xxI8sCOT;8qSvkG`hmh#IHZD6^MU=Oiv~?%W`-Yw$pj4b>=wn`y#=^ z=q8dZv0cV9CS*3F;=Bf+lP-d*YkySz{`!3!$=Trj5_q84>1x+X6OFOhi7tM?PUw1J z_C5Ej?m}@Li?f6jKLfEbt&Ya?5iE{yz*U-!f!!-MSdm^W%i2w^<0N11 z*Q%4JJp(w!_Q3WFodTSgD?(SbtGuBnK`A89E$=ba4LFg`PR>md8C`G6KzFj`?F z>paVNZKcWjB~lD+prWV@M6ynFzF!FOk{<7CrJTk%{!EEnfDW!#=a;8%04C8)nA5c@ zkBP*aU&h#QnFP z-A6!BDtolATYOUs4d`c*bK|dFS2M>=$(Iw*vpju)GoFLjp6MRr`}*zH+v0bJw<~06 zH%MLg=wWj`D13S4`NqfpNH8<_YbX$f$?v_|K!v-FtG*yJ1s4~7Y;PF)4&v)|jMi=O zFiN|CnXc#<`PYZvkbGJ$-v`B{dl`vbSFM|vE?322VPzl#(VukY%!UjkbS$rn=S>}audionHuuWuy!PS1ZgQj z<1!Ll;N%#9FPrI!AF2{dpPFxHD$&p9i@U4U=;}R$b9BhQ_7T6lnsp72{ycA18c}21 z%W$V0G*Uw*nBRRedCRETScfnPhf>FT`1|c6JQb zPrpyOk2 z)bu^NS$ypcPz^l-UY52$hny)7{;mk2gDVL>o}usJsfCNC9LV#%&33_gB|7Vg4F+0> z@;Mc3v28&Am*$xmuUO&MwvEKz^>-!={xTH1`UdBOOc-(T#QbDV)ERduVjDyg0!D`r zWKin6!9ZluR2AU%R$}maOqm&+z~nZWBB<>g zc;c4C>2$Zu)1hjFz1}A#US)3Pzd3z7O-B5Y5AI)86lDus1EnyVOZ2YZ3E~cE@T>00 zr`{clK+;3v8!ju zo=yG_RECg2z|_y&GI)v3>PdFIxkT8!Z}zKf0opjXZ@NuFf}vSFW>gN}1YY^q&x;iK z1RdwyF*U%EZH3b=;gwTt_~ni{1&P-)%J=y-FJKRfUJeh(V!Bu|AGs=*tfoalVLYCr zGFCpV7(3nmbT2Hnwv?-}7wxwRozK{~RTgegKxH|W|DxLyn?cHpbwn?evNcQY5{IsB z-|XhOV^Wzt;V=a+W^{*%LaDf4~ie-eRX!KP9#h)qL0rp z2^BVWXc2k+e$03t;W@b>$90>edV1gk>*G$COV-OFzA|A~x0$seH}#&5wf+vH$h{O=mWBG?TG{9yP%@7+^Gp-kE=N zo&WV6P2pO{pO4cw=!?VCg>%Iu$_IZ=4>Q9l{>mCOqG|4Vc^UJL{z6dM{V-kK6Whn~ zF>BvTZvD2Cy~Y$R)lf*QMlG>8HIV@&0X%uMJXtB0nWu~ZqwAg%O^QxZFUY&fH3mF$ zek*LIg2+KKQ2OT+tG05OkZ-F5MD{Nc^rLii{+HaX7H{Tymx?|`tJM$mv|3$r^ay*A zu+S?i84R;87HJb-CPvp#3QsvEfC=vl2h5KW2J|vbeZa(tFe*F82=p>#e6L!b{-X(5 z(B(P4VMDYdIxxHK*keUYll=95Obv#Id41Y z`Jg+Q(P|ixl14p`(YW+HyX9)^^xJxer|txBhc5FhZhFHdD^rbsuJEmw(@(4`ZYsHJI8K?;I$fkIs)-EJceFe#~0u{zFOaH22J6O|^~u=S77dGIar8l(%5}BpW!7NSsMSo~}^e>kT&G)kJ_6$5={}g}3tfO|w`NPrAQLO8{9Czh_+A8y(8K3k+(zgVLeGQlkip zMSejq!9>vVx$}_Mj%sCBt5SR!TjjgF$$o&ilCf9}xAVC=xaizJIT0X5YM{l5kr_j_ zOCFIaZ1t>$5?tM9;)*Rp<;(Q$sOs1y(!aeTU%xZ` z=wQa8v|%sa>#{U+HvRs6{v&HzlY`W}7o>-DpngJw4tx4yS;=MsKVY;~PrBvq95B*s zCv94hSSNxnJsTws|5eZv*VHJ{7!mAkt~g{7nib%J`M?snNQ>~DB8BT+Qdiy32~}%k z&MfS?+)c$?T|7lsvk@%Ud?_A1I$UWhn403U<+FJ1JGH!$@&B(hxK9SELX|+k`t&7{B$x zYrnk-AaXzo1AESj1?sAln>% z1t~flaD>z8?NkJrhjMQ0g!Zi?o`t`)F@Mb7MCGf#(o9u9lu5NC_5)$=3eP7DVprmT zPZKZDg5~nAr*sQw11;Z3I*8ZyTGpcxc-zH4RU5yEW|MFCc3%Vw@}qeXU8xcYP$ zRdW9fp>2*df&E4g*Oc*(8iW=5-P50`Ir88;WfvmUw7~1ccj@j->NW7L-%LPiqfJSZ zc~AQSp5`d}P&-sBi#<$#PFwJSFVs2f{tb2W#}>Wnd?V&4*9XJG^4GL)1a9gn(OWU3Byh9%a|F~ z#0BUz>_~o;k>fOSJW~dNBilr-?ada$YTy$p+hOpf6%Nb}s!!uXrN*kJMRUl{uqG;v z|L7}o@jDy+a8BdvzDS3xOQZzm(BrdHx_0OBy6m5Expg-o-T8gUhZuM#88KnG0bfob zY|4G>g<`6#h)i`Xf53vP3bCBj(prN_w1s7{lKqnzR@dwOTll@cE1j0}Nr9<;WhF)jFPB@DOB{MH4Fvp6mBN)Zfpa|D5C++YH-}sdab?F`5C-&VSJTD8DSs_oTTAyh0?*~28J!qJquk+a zHZHbIn~Y#Q>Orsbr}wG=bglCpHt_wPGZoF?$c1F4ZgiK44Mwp&LD2gGy|8dI%wR$L zwM&Ri0@_Bml1ANReF*f`jVyHYBhg0H#XXE z-$yu_()QtpIK77g-u*O5I-m3#){ISZEGwi4%70lG-VlB)Y`3BgkFW;(fOFsZa~k#^ zU)Q&np?AEN5f|(3{W#LU#~pk>3L(D8y^+z^}j%J*iOuaN{2WRxuqI$%*m5Twb`=yrjL-1nsJ3 zNH$)mt>(`~5&>|bb;4OP9Cpeq5}#0jScNTHaS3#CjDfbI#jwNrNOF+ea9mkQb|!3 z5z@=pyETxPWee5@E4wl5k?_u+?K)KWwkBfx zNyU#?o`EYJ&36Hj_%-MJ_u`;^_Y!K>S6ZFK`X!j!voK&53l*tz;m(#|*POhTWT9>N zlDHc0VWI@-99Ay}b?^D8Fu&zqf^{q(&*17C>uH|l&&0PLMc+lFn!LlX84J3`@Mz^7we1v zD8N0*5HO=8aL*xCn|`#um|S_Jme~4Y#KcK!=1Vd3URLdQCE)9GRKGUjw8JAj3O%0y z2r*(bV)QS@Liwyn#nta$F3v^zZ0+8>Kt3`b?GKAj1H|u-e*{fQL#$2Wu|`=UAnS{` zQP4GlVO=}FIAVZKJEE9=2)#y~uloDZiW4fcfh+wP7-$+fpGu0GpN25A_B_8Fkqro7 z%eY~9+Pd&P`0aB3@-|BZKuYIa=&X6lVg`luFf|}{<+=Mly%S04nJj}yo{(tL^@+?- zRacGZ>wKX|Y<~Al;1g|l9UZ_p^klAW9@*RbiW%JH36m0jo=K&w;^EI{r$r)9BO6`r zuKEd@nOt9mFriBxUFd+VkAMaquQ06jrS#%R@^{MTgbWt&`}p`c1R7P+*mcSC?q|{>S0G5T>9zu6XxFg z>2fY;(;y>nAi;W!j-A$w4X%Ib-)h&KUT`I{#dQ179M$i{WD&oLd#u(7pTYsfA=NPb ze=JrR*aST)ozRH)+#SHIPLVaAZ{F*8&^gcT2r&sXpkQ{tsRi%?@b!0wCMlTHusvT=wj8sFsK2;XvRA2E`+U)^=6!vS@d z9(zn<@s4YrU}pIvfJQ!aBPA!cS1}X`=uPtM@kE<7_zUw1bp0- z?kUXfF**>QrxpBw4oIv#7AEaX*d_=M@AA%Qt_H{yW<~JvvgMq7VeZ&s5*uAw%C8W4 zE*6#Tb2aoA-fJYCsSap`W;8xLxgREfuJ?y+M_X?N(hXrE6t1ec-;OARHsi3yyep~D z&%oBUUHv=`4&YMH;sdZRfi9_A7r&?jn!mGF%qk^h<9aa^x;xbrAv9IVEmSWO9;~Sq ze$OF5iClZ~2MOOA{n^5Z*xtA{RlXk*soC&^I9?e zq-Bc;D%;uRZsFkZlq)$AhNt{$HP6q;toVK`v1nr97XsqO??U=ln?v9Yh(ZS^W;PT0ZWbXn=}p7Xwr zPEQh?(!1evUQ*=W3q2Zi-N;*_iKhCwLV<#rZ%En|W*nAc^WRF(B3DU({@y{?0s&Mc zVgc#aCe^nCFF4#oot*nu%FX8wAio)DA^@3a)3(ZFWJ|Wn<_xwUFPHG^AA6Koz}D)3 z1?jaoj??i^J^LhTU6Ig3xGo73Lhio%u8$P7kH@M`6d=$EHV(++)7%h5)9t20|4?j< zAu|u^-r|_lbDTi+b*tl!Rh@aH$$NKlGre=)e07WqHX;D48P}v(_{VUWmwt#Ls2{+i zpJM2dCK#-;%#ogELEecH1CaZ&$8^SG-y}jW?Se^|&B)j9!@A$O8{h9gy7J8{j33Zm zp^=I6qv6NkjzPyH3?rLTjNCkCJP5x9pKL4srvF76eD3TU2z1&LD{B7f0t`p^MyH(E z7iyCNS61I2GZ0e5++5#T`9y)_Bn~!6?W+mjg!apt5IuLCJkS4$E!Kc4Ab;?r&foP< zR_+2TTe{wlOo5xsZ>o=Zr(8sBh$nKmB8FeT7jDBoR$W52%PC_hl4r}Eot@c5q|keI zu5QA*Ll{>=Ze#cCGg%)h9O~8~V}J0Ql_!>{LiiL%*@ zQ*biW3iihCj*!%krF{Dj?UoyY&%jZ6zDm69!_BsrPUc;#VdKtNBS{tiN;etHA&%N5 z;(fQwRZXhqXg~fSR7b223jnl5?+6ZHq79+p4Qj&%b+{xH^!= z*|*xubZUb`Qz{M|vk+$l!U4#k(>qxtk^QD2LLc#YEXXtwEHG@AYaoF&EJFuJn10aMv-`ZjvR-0J|*__Lj);{-YrE+LSLr}~Wq5v_l&%m7T zLqlOCADBwfhJr8mp<8r@JYXPkQzpJTV;nSxHH(diHu83Mms}l*d&@B?v*uh6Tk^7l zRd+qIhb**&2QNu$>X|0tQeJ~uvm$CzsAHui7UGK&A zFeS!lVD9{#hJjM2{CeR++irG5z#Tupj3`2GJc`o^HA$ zabetz{%qfLaEs{+je&Ko#psiq1_AEJYG=*OIun9+nZjjRsCwkj!uE4>EbvB$3&a46 zog2GDyM~ki&0mjE9A9w7t0TW_fy?pMCreHrH3NK%>asLPC<-k#OG6mejAn4^9b?JJ zsk^G&i^GR{V({%@wWUz`-xh3xoe{r${)AeGiqf8=B`@#4MDb(j*HX4PPY!HC&MN#1 znC6xScX`p3IYIOrhU7j{1OPwPqqS`)N37D`|I{G9fGf=G}n z5l@P)aZ8mC7=5VlW?Ljr7I-~x&C+?eu8G)gzb&6#VUWdMXTsZi+v3-nuH4`~Glt(j!tUI#>~C?2W1>ZXoR$p z6md4GLZBqdA@4r;-*2f_qo1iJP<)YXEL0C3LW_6Af*CnEGOt)3W%_xhhP8FkTB-^r zOMkd@v&3t2VI?E)T|1)P=^#3a%}p6~Ipm53HsLxSV34V5Ffvp(W|dTud-Jho zAg+(f)=an{Hwdun*)|4zIGVl>ykH5z%t9LGnyzVqnz{yoHB$yZ{-SJnAN8N5P-vlFeC1$2eg&(F;QZz?exwE9H+U>){bOZnWq4 z(dR7jT?tklW$;Y4P+7bI5)eexF4{uD3WIJ)eSvyq!HM2Iq=_?t@*c2Ndq& zEAWDYqbsBE#P>$|GIkywe|q0t_nBflzp%A$bzDUm;Zl`Gce`S?@G-ThkS%Dx@w7B=8Fn~MJPkFM zp;sH?D@+KE$9q8Alo;3L9zu`&*q;1xlc%aQv@#8vF4T;+yGvyN!%Kz0`dT24j@J~Q z{}YNmj7A3HH_5vu+NQEp#j!#!E0b%!J-mY;kp7dSMX;@Tug8oGZ^9BtEjfI^=xTfc{2$n>YuthpMKp;iS>fHcV_Za zI%u?++-2_x8Z*-m*@$=-jp2luZjo*$QWvzLF3yW_=~_@rW=QGC8Xlo^6I>|yY9&Uhrna+;sC-$n=+)pvWeQ$P# zDD69!`e)|~GBW4uht2~B(TxR>X%L(P#=RrI3u#b>y+|QT_QZY*(54%w_Tg2Zs7KpJ z=^7q5($e>R{C5D4yN!%Fy7f*kL0vgMbcYVL`~A=}FjVxk9rFj_ch(sQR2wI#%*Byn zf@m_NFVfJL5L>eRphgtN;*dTJB4F=C|4K+p+D zf4SR;-X}T%)Y0?N+OscvLoA7-EjurliW#9}D))|UQi{WGN&-@^ot^G8onSWpK(3a~ z_}NZI5tC<#CqxSWy^#My%+D(5uHx`s<3Y(KO${KJZll_EZ&)`EfE9r zxvNz1pPq)}9c!mU*sFgcRyvBc|Nrx#gUKvw;rqC%YHskcQjTieSsJ$0Ks9q1dti{; z3htnFN*FM;3eOo7?ZvhW8o6`>{>FT;Ah;ae<1nn`r-$d8I_k^haXt|73NPCIyxz+z zi2Y5$7EhC59466B*?*k!U6J{1-Muv`0&H=HeQZO^OHH)eVI~Q4U}=2yjK>oE+miUm z3}gISZQclpjk1~jUiok4GsI_+Q#x!5x3z~WJ51KhI!$_Gw!zBN_NG1xNrC6>$LbA* zJ$tOh*2w-kW~G+pZ*K=GLFjDy8DlU6_fxi#KoeB%IvjpRBWi(=XT1F1k(ziKFAY`ggA?|=I_uLgHh z&6PM@kg@Ejh{jNJbZ29AF?yFe2_kCt-Kt^j%06`N)Hi0Uem&n+*5NRWLT@l~yY8 z21&c4T7GOo-jB~Q3wTv^8^+|wM(1?2kzzK|2KO?TgA0M#xXFXNmDfo!WD%9{-pf0G z(7Mc=`#UPgskF^e)_u}P!vB%dCE&TFhFuhgA&)%K!+)yy4cxM8+@Vj;yHi*Kb9!&mk`>_ zk0#YQ_gFw)ue30EUAjy-Ogj|-$-NG14r1q=>fD$Ze=>3&jHM)9th*kJO?7J$QG~^T z@VChVK|@sgi^yP8^>^AITf$=(#wOh~w6%3+-ybc#2!ikU?YYy$MPRRYW*a7Avi)mJ z28)tGH1ijU^t%6{l8%Nk$n6M%VB=3z{5f9Lhj{n%bo4c9R@gv8MDt!OtZ?LTAE)AM@0t0|{n(>U;@bvFU zAa=F}26k?;T4bTvpImjZxOiEJB(ZA7$cgMu&H6DZ$&%P*?Ef70VQkzsiCzk6c^PD> zIy*KI>f?*4wA$c4Mc9EBa%9*EKfYZM1L{b97NHxs-bG9%acV<$u1aMeL;@06Om&z( z5JnoL6Gt{)@@3d>UB;I$>@{D`9exqp9I0*zg5nsjP2&%P&T96vRTu_lgt3HTqTwA( zc`7N|Lmcw5BXXNevM{mxK4*6mip#wRYfKWjrXRe-*Y9m}H7qmEU$nyw7!&`s>ccYz z8>d`H)7i#Ta6-w!%h7tRo-2T!Fe{&X8_uea6r>+vGRq~fJJ z>z8+V%nv~ltDE?(on-xP0MBtuNCGBa=I`E=wFj1kW{zMw{Hl)17ixq zPvh`U58trOR@S(v*28z!5Ok*}tNw>ns0TNT>NY{*2>F`JRk)xLU{dk`smE`#W9V z0EVZ;tCC`4`UJrTx0)mO_WAnW$}^bHIF}t=y6ZS4n==hzP@7#yt7}g>rVtvRiMHJ33IV!>7 zofCg|Jb~=PpYHZ34Dl94Vx2aSR-9b4SlqT{y&@0h7}?-L95)|%9vrDU<;NQ(*aKN^ zB;xE0u4R1sH#2*#>vfVu%2ZdmPB0n5^XDkUd~DqC&$ggNoY)!cfAsgivk&36Osrzr zS&)SsERx#vr_xjPMx|M+0=(a(J6J-@n>B<DdI)82xVo4Sbn) z{e+vd6`VQ?+}72kS?B8HNaq8)Ft4oJF+8{^{XV$dl#}xLD4jkmWUicGZ0&owXi}Im zH}Ui11@&wGd?K8GH}D%92L*3bDE4Iw%R31Z^L%%52y%|)*L;@mteFLy>8=KMmOI^d zVdm#h^((d#G;m=cU!hujBQmGKok`2R^fb?gq4s4}Rv` zx4n3~4I<KU- z{i&0G5zF?2st@_`!y+F^-8?23JJZa6te;)U>boFynDdGy7@5B(B%Wh#m+5x+^&?#% zG;u0xQYGuu|z}oZIooxV=z5m>Fb3aYMvW zhk9Fke!uN4T13-?vV=1E@9zzEfvz-R8+kw6n6F4&CtQf}_jems09CJTZQpONL;Ie9 z9yElxiJza~l=GJ#H=zL}egc+|P;PyWHA9hjgn6AqjfX#vol0C&n45S7sDD4;>imCO z8li8r{7l|ro&!~lfT+uPUxarm5usu9 zb}!84>=Nwk#m&|U>W?0~U91^{M`{LB2L-Xe9gApANyg;yH4wp zHebFU`7@)r+Ckt4cM{f<9!!s&jI23DAb_i|I3aaE+@=H#5@kJqa?~z?c$9Rp!zXb) zFj@zn=wEG{fHq1JF5TTWT{JfhcV@lUp8b$7!rog}5mQ~rwy0hY5}bsq-o7Ux#ZF8Q z-&r;QV+^WYXpG=CTrR>ni$7+(x7c&6_MAhpd8Z2vU~b~&r(Gr$6O5OEC=_=bz0NWl z%A<&IH76?vJz$=Oc^*unEv4}4F(Ev~>qxq1bkYLUU7eKISJp43VuvSEziqJ-U;ml! zh_@k1#w`atBS@OAII@vSeMhle?FXpuha_6Y#jE^c$Zlg0*DE_;DNEGy`8YaZ_v=6R z9j!XK4e=ActhdEYUg|_T5mFpQab8o(D2`bh;;aS$acfh`DITW`gw-8!FUz`8jd7Dy z;yhq>x7Xap@xiU9quAHRS(p-{>LwWLOgA_(T5y|jV_8;O*lX&fefGqYxZs+*3Px*C z7s3}|Nl+KfU04yqclykkGdC98ne`MxMxc9d*)G7^A@q#LDz@)QNiNi;@=)#0vT;dK zS(6MXe;6@-VkzL`@!Rb>$C7iXMRp{)sjtZjRUeF{q}?W!Bp9#D9car&6pAarH|hbv zl!f5pWR;i)syT@z89?TEJp5=Zq4=Yp`Su_ZZzjWL68K71Z&FIRC{*_tV|nh)r@Jr)kB@EK0ghG?Q9TO6P8{?S z86tbD2=Q6CPW9Y{HN>~S(=a`|V5}|vgyHQroak3oGaVNG#OKtq340oU#JgxB$l_Vf z&IV1q7+4keWjJTu^S|}m&~7%<1=w=bxwY?AV52FM#J=5GHW~gR$_2TE| zkz=23)k$q~^wu+fmAqZ1L2+jnmv)*~-VY6YJmK=hrNO@c;Dg)-YqbwGr!mPc(tjZH zxNiGI_}Rh-_bInI4jwiDW1VTk_h!(^%nka&V3!PS8h^;!|0yDYnm$S1$ z6E2#AOMMvxh^G(+)m)m38L4wC`JQFhA-8#YxP>QQ}+H#KmK3)&=P{1*w^Se9s3~Llxnh~ zB-{dF4iOIjM8TfK87r<}?IxC4MI_B1t&5c?qQ9y;Bwc&al?)di{`_~0C8o}jv=7|_ z9GR{K;4~rp!@=4k4&VI3zcqC98jKAMO6Z4FuBePzTU2Yr1e2^+i3F02RPTZn$Q*Pc(6%XBk*V8<@Numh95ycgGz@a?IHY+&u90 zd~SJf#eWva0yvxmV=jcKj5dkIsS{)s?zlLNb#32G)AxH`0e@JaZojK4QlTG<)WN7E=vc>;qbM_IWQKAdo-lia`r1?9yTm9a!WmFnM|T8 z<@ua!|bj zYlLs)(_Ro+ueGsorpaOpC;v;5T{;y!AciqR_+)<4b~s)&8!ujc{ygLktBA zJxhNv`*hZ;eK+M`^w*NNn;J-VeTnfCQxkHEp=I!02*HfCq{j|C<(!+tF}SJN3Ahc$ zQs6s+FY|fhN)WciGK&|B>o`0qPR%d@wQIGwc%j^gPgHYtvWOS+U>)6uG4ax0SMqs5 zMKBbhjsmEs=kif0A$D9JT0#fmaw;=r2XuTRJGBv{4#g>4-cNO$<|E0%%>fA6Eej%- zQuZe2{h0HlvzQs`3Mr2am}Yh^tjJIFVr$3$XtgC%w<35ymK*@I+gxHaN<(26c79*^ zepx{FkDKFe*W=;v0jYh5b+KMr9EjID)T16GP#ncn;cKu~&{PNWNjjPgqiLqeVq3L{ z0An#Ik=QUACqyGGG6QfuMB;U%YUsBQ`(R833+?w{dN$h^p@KWheiuUOu$%2M*dON` za)aBQp36WGiG91;ffZwZ4t(P} zKT5sesy1ky3n8HSvQuHDCXQ@3;(Mo16^DCj+n{=3(msQE239 z=g5!bg2kMHoBh?bHHe_E2K@sV39l2i3my}Rmx-}qG8@qpx9Qkw?#sD{Pz?~FO)+vs5;?9e*TT*??OUh!u z*N;!an!qQL(+*_U2g6t_6AxmSZs|6W*q9C$(XN4E%n;51ocr-=`Q3oVQ|Lk?m@w4@ z{z9b>jnHY$d5u4QZ2RNKIamSwG5GSVMEE4EVHc)nU;~V|pI|OnMUgmxUFViDW@a^T zUQp~xhOL%gV=w^~F_!ps9vn@?W60te;Lrn_g7;%t^eMRY7~-~3qWI_F1cKXDS2Q8`^n z1Z0l0Aa)Y7^qi_T2RC4L9zzt3oueNO+W-sZ_>T_;T>7l`it{==I+q3jBo$AAszXCL z(bIcJ$BRVRc)T${UH_2l9I)D~%q~k7ERHQ?xn&|FTGB4*kV-C)MG9+DiO!ZIM6rg|Lg7+XFj_ zL5sM=`f{m(Ayp?}3_d;v`$r#TFMSwr(F^I^QlZo<*O{(qEPFDR@HJQ)sxC~;Z~%s} zP{wyM1&7P(_T;Z7et-GjI5&V9)9=4Kc;0y>-`Yg9wd0=u8~9eqnXDLu+weDsL8P$j z|H3)nC71)L6C|wDSWT#bb6&h-`NT~T&Sx5Nth8l7yDq?_ibFf`$HZQs`SDpEZ#J>- z{dH^~jMlPd=7KI5b7zA_ryx6AF)5x@k`82s;-<`!C5w3#jrHU1b-v9!j$KyS5S2;| zC3+!=nyd#>RV7m?hc?`U1@qC*f4lF|fpctnjbqJ8L;+@TyC{k>;8}8W=jkFEW7c%V ziA?!3qS7bHl=qL=h=Cp1Iln@LQ(ibxcFCqUN$=aJC2xQDbKi0Er62#VeOM%j17EC@ zIcx+=bI6;CI{%@3G}WEFN6v@ybPMe#!SnHe-}5Jn8~%^NpZ`q?x#7{})dfRQ^~Vu$=fveXi)hx47Q?oyEM8k#0lEDykA!MZa5k@3TXATLj+XMGZL#YYRhd!O+>Y1v4Tz=H_(Q z#ckg<`&AX|creE2u7!W>I5|1ZWvyBSSyP1L%+}T!?iiUI9Mf#ma#ROt=tLUE_9n-R z)Q&@xUhG5)$AULu#@7O~aTSNS5QGL;xV~Z>4tKuxVD?OM-e~8GE5e3&OtT?X36ObG zfRs+hPF2z7VqAis7PDo?;E|(lhXAx|mRF4R+yr?~-{xPAfaqud3*@7p|MqQP8Qq$^ zo$D#6!CsE%IOL+l5d4?f?BcEC^rZ2O(HO!X1FyN$xBFkVs}$YAfPqgGCKb_0s`( z1;!=2uT_Ldil9&qY)JbJ>hild;B!INf^bqb`ALay!1ur~CS#DYg`4Pe4lqlD|@ zsR!9zc4y#o3tfB+81T!`NqRA5z~aT(9t)av75m?alQm=2gtYBWPa_e2pJv?o8qZsN zr|Y#zudC|ki}riEtayDFyO3qW`kOV+s?#c1RdmHH-%fZy;-S?fua`ZUNfve-K#R4( z!)kx*oL$(7MI68m;d0&|J~jG97WLwTqCZ0TZVFrYbhOnRg_v7NUBJn7pU8;8U z4j6|q2*;;sMPg03_}bfh_qA{l2aPhD&I;$;g;}+4NCNSeuaBM`Cs&367>(i2Q|`g^ zETvGKe*fh7@a|NC*Q}2P+*#L!c43`1Eye$?&MfjS-1)@UA3IH5SK06B^5R&iWzm<4 zz{p^-u7Z}0QHytQ$n)o2c@;8~T)!pjAP;)yWI#8n3&B}P2c92L3&cr;u6b>f_(>eDi!3jr zlsks(zH+qs)CE`&F62h!?NioVB&v(v0fG^qFK9I_D{6YL0Ir|5|(!Mk9znYLXh48jFKlP5Cc5yZWjFen!`KC}y zzMo9hg+{0bU^Gc1$Nv_Jm`~$&`Tc9|_@K_2W;OWdHZ(@nz&U%&cQ;xF51@S(nkSw& zCS$>D&KvFbboq#~YlSk`%Wh)Cx;V?CFb@Toq)y zHJBse0ynI;c_+7F%o1EHSLi%Ckb*lo91Eaf$F0BMZ1B7ir#33tz>@7`Hc@5sJ#S2c zalA9C!Sfem7Q`F$S>WQ^>P2)!8Qd;PUR0NB*@w*HW6i<({8Vs=Cpo!UlQZ`WWIMzqjI1lG{ZOQG7Jt){KncNV5-+#x zm-L=?GOLIQC?cy&SLMpl6|{Neh0YCF0NgN3Cq{00Drf@4?#`VCF*cjDsT(F0YttT6 zqOe}B-WRofqMgP0J7X9|bFfG}Tet1gKpZxHO!V7yg4lfK?4)ge1XqY} zOQpaec_1j%C=e99tR_(kooWBGYGuUyzwMdd{-LcCKP4VnT zL9q-p2gT6|P@J6O>v7S}>{K3cDG9HM9l2S|jO8LIaTvrp;oK#;5B}k|o$a`A5{#IW zfw>k_1uB7?vZ=Rhj}nVx&N4sEZsIJGW+60>V3(&;7A)DZ4B161>qzUvk@<=MiqS2e z<0?vEgJ|18y$^rkJ9@AXif&#PXSuAXT-2rHHF3Sv_n2!&hq?^>ytF;}Ht;@Sp+CCF zQ(KO2n^bHV&9QKiIJk*tagye@X$M+B^A1JhNi^*WX<0j3TQO}B$EcKnjD47%&E{Oi z&AN#f-3g+x+!N3?7IX9f^-7G8?pNB?i`mPRRl~~2|#^7RRhyDPJ*5Q&Io5Zv- zJlH|e7jskr6xS+FWY*McCsNX0al2}<7=RU4mNyYc1jB{kMPd#Tr@Cu5P#oH6{5h9| zL{0-{p*Mbpcy2vN8lsAlIAmK#-e)&)NfvS$=W#7`KFSs;C@fA)M*7v{(Z|LAkue!II*s+%Y%W)VC~?H!XY_qB< z&qY*LnY_G@kj37#7ztj`Wx@KOe<*3mSr9IpgYs@FGZp7EuiHO){TsjdZF3#i&KIG} z7H5?IMF`e$5~)3~)SkD0BuZnl0*F(YMQFXBD3_(|wW`)$C;d(=E7NCL(Q9C|nT2MR z`LEh;$Uub!l5PoX?j*jA;)jqNiZ(Lw>=&9ZFu$T~lY<2ikn9mcZm;N?c#xqo$YZYOYIpOWed$YsV7N0Nnjmb%M6h@x?> z5C1pzLC1gSmE;C51M1ZyTGD=$eX1SKxSoU9O?6=R72T%6(u{*F^oQG@h_~QkyixIE z&{pw7@W)hNJ{y>FTV3BTe^%r0Uo?r9F95C=5Y+9!b8F9n4AFeZMD z--0NTJhBu`B?qLw4}qook`GFd^yo*k;73Hn;_!hX!Lm4CvUoV52m4 zZr!%_J!n%WDc|wdP#twnK`XH9vi8tib~Q(}wD$MPf0wSUTWndIaUydiNCFJl;W{FW zB{?jKvs$0zY2!LhD;U-yCM3_dtTTzhh+34+B}5T7JBV}X^$Koc=ivL(D=8bXpv?iq zTHJ(Gqd94!79ml#EBbY_@U3O>BtWA>!YqVV;t*zMa&}!q5qo8Y1a&bVbDb7JHBDhR zY{)WJu5Rs7ZS`L|MQk8vC|+U%xL) z)-a+Zh9qHL)|T~PRD!-tasY?A^H-N?e{{NFv=|(~g77`x4G168(PXCVB6Cc~CG_sw zt<}xa)a`?9aIiiv!S%JuC;t8%<92)Yg}VjQGlfurja<;C4#LH|1)qm>%j~)zC*1%V z!NjR1@fRZ}p#j<^EVc3b*4%Gg6LFd}wn6F;?7P?!ybjUSdE@nEIB#q=&c&Dp=f$sd zFsTcQS*R=`q-573rs#*_suUON=8@xL3APvi2xDO({iYI zlc6T@=5p|Gfb1}ipUvfzWf$+Ud{$2h^LxylOy)z{5t>a@VzA(33z$jePFS9J(*21SXb&(KUBYjvn zjb+NV$_4X0@a^maFs7!#+Xn}W&g>o+KJi~s*Cp;kOJH$z$|$JG=VofxzuZs_?5 zE+|;*2#Uu8XofBr4N^5B_O;MW+`ivGK38gjE=-BPGHv4eGsIw+SiE4;c_TLAG9UO> zIu{LRS{jaXF{Z$I(RV*b805jsyxJB;GRh(`K#3#B0-ZQuStR@VqsJ%4S@}l3_!KuN zvgpZ!zwv-@MTAqHO`etnRqVoy`RMBBw!JWTR0Ppe(=^w?yW_0;{?+bg+J{M+F3w@z zpOOQ@{U)UoMfR8EZdb5AR1KU+w&ixK%)J%)Yk~nR0RO$}Lknmsc48S%eE5XTZUbwE z9yGwRwxiW`6<4jw@(vDXmvZEb_pGEqvS5-vnf zrurW8^$<4a#{H|CZ=ATaKeJwDzy5Tx=u)w`UL{AF)J_l=Z!8;??3C=XeoW&zdyl}Q zF7VpoYN&iaq|<`;rzpl*m?luJZCb0$x&|(k=W}Pk^Rl=1ZWorE;0EyD*r{(pThz%# zRpC3ZV7NEG3)8c7F3>`?>kr14!7du6q>7? zYAQ!@7nYFV=HA-+^L=P;(4nOvoFi;)oeAf>Q+>Dnd34(h14|3LL8m^}dVy)|_`cb) z_&oS0;jb6p4~8n33VIEurYUjX^Q6RF*ttd4+r^$9tckPhT~f)hJmOEg=PJaq|t|0Ki7pB z@$esi?y_G+w-hL;t^^}74g65cS$3!+5|YbU2I|8k$d>v>EOj7eV-_ECZ3C58#aUe3 zz@z07{Wp=9Bky67KY^E>-ObIZD^~I&uDtC#(khX!@ zJFVMm$}0}8^KXtN*mgR@SES9AwZ$+A()%NoC9N~fQq}UbX^n2zZb(R+OaAqGvC+Uq z7cNi)84h6aG;-k@dh=mPS#3Ay{P1-+^)YUlZ^vtHu(eh(fc7!%F?%9$$)9gc-M+CU zgO^TCdc&FvZhpFqzA4gBZ^bDXGx?n7nCe9oE zGHxy7?`jiKTE=uZaCDai*bzAqR4yyXt^W~;Okjs^ndT*R1s3;M1VqSj6loop zW#lXZvT!`?bb2rY!T@$&{@cz2tAWm{1<(p+EUGI4Ynn);GJ>hZQt>L97n zSgHNOwNUm4Q4xld9hjuvQYLYHhN=Sqv-l6C0|1JznTk-K=I0&EYZ2#cr$awF?%;*b z%MnK>d0n~vNxiVJog+AML4wQ^& zOwMc(^Esko-41T|U<#)1g*Oh;gu|a5>r*dRcuU3a(7`Drl!4a zs^_DT!PbmcGQIPs4TG0XM?6lh<%O+3Q*J^~+_dp3nxg7KGYp_5OdqQ=YufVrTs3kG z@>jLy@LwQJoi{G?@2Z(p7E8FX|6UnxO$l>M61{@tRXg#XOLM$1`yw9_>JM6QhWR;uh9AaTH-$m{sG*aV3cf71dyN z9A%KjP{@ARY%Ug4GJhuL4kuAK%hjm&1t~Xcl8q&ETBZUh@n6aa#lx*cY2F)H_Hibz z{>0HEpSlV&VA!Ev8stw6^q1YwY~f`ebZ!qtB+mUAahe;ycQ*{=66=&Bl0}@Cq-K%e z0g7{3UZAWw%-^Z*!W!Z0s}IxBwF`u1X~N;D^u7B6<1SkvT+G5&zrW$Y49|7`$|HIHz`8m05}ANG)mHh4al{F$+{FIh&IXPjz>aWF zA}v6~I2H(bP&M|*)⪚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 E01 Date: Thu, 9 Jun 2022 16:33:49 +0300 Subject: [PATCH 069/132] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d37ca43c..4c1da07a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Grafana OnCall + Developer-friendly, incident response management with brilliant Slack integration. From 98decfd7221df09eee1dc1249105b1dd7fd23a94 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 16:35:04 +0300 Subject: [PATCH 070/132] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4c1da07a..7c852218 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + Developer-friendly, incident response management with brilliant Slack integration. From 4f26b357c0c16c372602e976a0e325dce8e519e6 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 16:35:47 +0300 Subject: [PATCH 071/132] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7c852218..4dae5bdc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Developer-friendly, incident response management with brilliant Slack integration. - + - Collect and analyze alerts from multiple monitoring systems - On-call rotations based on schedules From 14a4f4984078996c521cbea99a98348d9b3edf4d Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 17:01:46 +0300 Subject: [PATCH 072/132] Up --- README.md | 5 +++-- engine/settings/hobby.py | 13 +++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 4c1da07a..a9fd2fd1 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ Developer-friendly, incident response management with brilliant Slack integratio ## Getting Started -### Production environment +### Environments: -For production setup check [PRODUCTION.md](PRODUCTION.md). +Production: [PRODUCTION.md](PRODUCTION.md). +Developer: [DEVELOPER.md](DEVELOPER.md). ### Hobby environment diff --git a/engine/settings/hobby.py b/engine/settings/hobby.py index aa7ad6b2..7495ede9 100644 --- a/engine/settings/hobby.py +++ b/engine/settings/hobby.py @@ -1,10 +1,11 @@ import sys from random import randrange -from .prod_without_db import * # noqa - # Workaround to use pymysql instead of mysqlclient import pymysql + +from .prod_without_db import * # noqa + pymysql.install_as_MySQLdb() DATABASES = { @@ -22,10 +23,10 @@ DATABASES = { }, } -RABBITMQ_USERNAME=os.environ.get("RABBITMQ_USERNAME") -RABBITMQ_PASSWORD=os.environ.get("RABBITMQ_PASSWORD") -RABBITMQ_HOST=os.environ.get("RABBITMQ_HOST") -RABBITMQ_PORT=os.environ.get("RABBITMQ_PORT") +RABBITMQ_USERNAME = os.environ.get("RABBITMQ_USERNAME") +RABBITMQ_PASSWORD = os.environ.get("RABBITMQ_PASSWORD") +RABBITMQ_HOST = os.environ.get("RABBITMQ_HOST") +RABBITMQ_PORT = os.environ.get("RABBITMQ_PORT") CELERY_BROKER_URL = f"amqp://{RABBITMQ_USERNAME}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}" From cdf22c36815582beb68ebdca92baee866dc3b469 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 17:18:40 +0300 Subject: [PATCH 073/132] Flake --- engine/settings/hobby.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/engine/settings/hobby.py b/engine/settings/hobby.py index 7495ede9..4b2a4e8f 100644 --- a/engine/settings/hobby.py +++ b/engine/settings/hobby.py @@ -1,4 +1,5 @@ -import sys +# flake8: noqa: F405 + from random import randrange # Workaround to use pymysql instead of mysqlclient From 83bfd1235b04f4c0da846b13db2e629edf941dd2 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 17:47:33 +0300 Subject: [PATCH 074/132] Update README.md --- README.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8bb9ce91..c84ad011 100644 --- a/README.md +++ b/README.md @@ -9,25 +9,22 @@ Developer-friendly, incident response management with brilliant Slack integratio - Automatic escalations - Phone calls, SMS, Slack, Telegram notifications +## Join our community + ## Getting Started -### Environments: +We prepared multiple environments: [production](PRODUCTION.md), [developer](DEVELOPER.md) and hobby: -Production: [PRODUCTION.md](PRODUCTION.md). -Developer: [DEVELOPER.md](DEVELOPER.md). - -### Hobby environment - -Download docker-compose.yaml: +1. Download docker-compose.yaml: ```bash curl https://github.com/... -o docker-compose.yaml ``` -Set environment: +2. Set variables: ```bash export DOMAIN=http://localhost export SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long @@ -38,12 +35,12 @@ export GRAFANA_USER=admin export GRAFANA_PASSWORD=admin ``` -Launch services: +3. Launch services: ```bash docker-compose -f docker-compose.yml up --build -d ``` -Issue invite token and get further instructions: +4. Issue invite token and get further instructions: ```bash docker-compose -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override ``` From a7c41045c6b987e3f419a7f65f37b15e1601c455 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 17:48:26 +0300 Subject: [PATCH 075/132] Update README.md --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c84ad011..8ba1b9d0 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,6 @@ Developer-friendly, incident response management with brilliant Slack integratio - Automatic escalations - Phone calls, SMS, Slack, Telegram notifications -## Join our community - - - - - ## Getting Started We prepared multiple environments: [production](PRODUCTION.md), [developer](DEVELOPER.md) and hobby: @@ -45,6 +39,12 @@ docker-compose -f docker-compose.yml up --build -d docker-compose -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override ``` +## Join community + + + + + ## Further Reading - *Documentation* - [Grafana OnCall](https://grafana.com/docs/grafana-cloud/oncall/) - *Blog Post* - [Announcing Grafana OnCall, the easiest way to do on-call management](https://grafana.com/blog/2021/11/09/announcing-grafana-oncall/) From 52cc1eb734b0070f74d1fa1819f7bed834f93020 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 19:52:09 +0300 Subject: [PATCH 076/132] Flake --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8ba1b9d0..e9d1e7da 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,21 @@ export GRAFANA_PASSWORD=admin docker-compose -f docker-compose.yml up --build -d ``` -4. Issue invite token and get further instructions: +4. Issue one-time invite token: ```bash docker-compose -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override ``` +5. Go to http://localhost:3000/plugins/grafana-oncall-app and connect _OnCall plugin_ with _OnCall backend_: +``` +Invite token: ^^^ from the previous step. +OnCall backend URL: http://engine:8080 +Grafana Url: http://grafana:3000 +``` + +6. Enjoy! + + ## Join community From 4d625ba264c309f4af0d26a7a8dee8af1f28bc24 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Thu, 9 Jun 2022 13:39:11 -0600 Subject: [PATCH 077/132] Update DEVELOPER.md --- DEVELOPER.md | 1 + 1 file changed, 1 insertion(+) diff --git a/DEVELOPER.md b/DEVELOPER.md index 75b32da0..ab4bdd31 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -261,6 +261,7 @@ lt --port 8000 -s pretty-turkey-83 --print-requests 8. Edit grafana-plugin/src/plugin.json to add `Bypass-Tunnel-Reminder` header section for all existing routes > this headers required for the local development only, otherwise localtunnel blocks requests from grafana plugin + > An alternative to this is you can modify your user-agent in your browser to bypass the tunnel warning, it only filters the common browsers. ``` { From 24e1f8c3e3a610861b345ed68a8b16aa80188ed0 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Thu, 9 Jun 2022 13:40:00 -0600 Subject: [PATCH 078/132] Update DEVELOPER.md --- DEVELOPER.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index ab4bdd31..4888b719 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -260,8 +260,7 @@ lt --port 8000 -s pretty-turkey-83 --print-requests or set BASE_URL Env variable through web interface. 8. Edit grafana-plugin/src/plugin.json to add `Bypass-Tunnel-Reminder` header section for all existing routes - > this headers required for the local development only, otherwise localtunnel blocks requests from grafana plugin - > An alternative to this is you can modify your user-agent in your browser to bypass the tunnel warning, it only filters the common browsers. + > this headers required for the local development only, otherwise localtunnel blocks requests from grafana plugin, An alternative to this is you can modify your user-agent in your browser to bypass the tunnel warning, it only filters the common browsers. ``` { From 8af7bffa274c3c33a994f9873d69016cfb723fe3 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Thu, 9 Jun 2022 13:59:56 -0600 Subject: [PATCH 079/132] Only execute release process on tags that match version pattern (#21) * Only execute release process on tags that match version pattern * Sign build Co-authored-by: Michael Derynck --- .drone.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.drone.yml b/.drone.yml index 9157d8ee..3fd48008 100644 --- a/.drone.yml +++ b/.drone.yml @@ -157,8 +157,13 @@ services: trigger: event: - - push + include: - tag + - push + ref: + include: + - refs/heads/** + - refs/tags/v*.*.* --- # Secret for pulling docker images. @@ -231,6 +236,6 @@ kind: secret name: drone_token --- kind: signature -hmac: 81b9b7cda5a8f8525f40f39821be50881e0c4cb0c40a45b3e63bc0cc47274649 +hmac: 5cdafa5ca416acb1763d1d9ac93bbd932982c874718f40af533914a6711c1a1f ... From 71a473aa978345b206d7dc48564c3bc44c42a9ad Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 23:05:31 +0300 Subject: [PATCH 080/132] Readme polish --- DEVELOPER.md | 4 ++-- deploy/docker-compose/README.md | 0 ...oper-docker-compose.yml => docker-compose-developer.yml | 0 .../docker-compose.yml => docker-compose.yml | 7 ++++++- 4 files changed, 8 insertions(+), 3 deletions(-) delete mode 100644 deploy/docker-compose/README.md rename developer-docker-compose.yml => docker-compose-developer.yml (100%) rename deploy/docker-compose/docker-compose.yml => docker-compose.yml (93%) diff --git a/DEVELOPER.md b/DEVELOPER.md index 75b32da0..c5704282 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -28,7 +28,7 @@ 1. Start stateful services (RabbitMQ, Redis, Grafana with mounted plugin folder) ```bash -docker-compose -f developer-docker-compose.yml up -d +docker-compose -f docker-compose-developer.yml up -d ``` 2. Prepare a python environment: @@ -119,7 +119,7 @@ host IP from inside the container by running: ```bash /sbin/ip route|awk '/default/ { print $3 }' -# Alternatively add host.docker.internal as an extra_host for grafana in developer-docker-compose.yml +# Alternatively add host.docker.internal as an extra_host for grafana in docker-compose-developer.yml extra_hosts: - "host.docker.internal:host-gateway" diff --git a/deploy/docker-compose/README.md b/deploy/docker-compose/README.md deleted file mode 100644 index e69de29b..00000000 diff --git a/developer-docker-compose.yml b/docker-compose-developer.yml similarity index 100% rename from developer-docker-compose.yml rename to docker-compose-developer.yml diff --git a/deploy/docker-compose/docker-compose.yml b/docker-compose.yml similarity index 93% rename from deploy/docker-compose/docker-compose.yml rename to docker-compose.yml index a07bb72e..c15cef14 100644 --- a/deploy/docker-compose/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,8 @@ services: MYSQL_PORT: 3306 REDIS_URI: redis://redis:6379/0 DJANGO_SETTINGS_MODULE: settings.hobby + OSS: "True" + CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery" depends_on: mysql: condition: service_healthy @@ -51,7 +53,8 @@ services: MYSQL_PORT: 3306 REDIS_URI: redis://redis:6379/0 DJANGO_SETTINGS_MODULE: settings.hobby - CELERY_WORKER_QUEUE: "celery" + OSS: "True" + CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery" CELERY_WORKER_CONCURRENCY: "1" CELERY_WORKER_MAX_TASKS_PER_CHILD: "100" CELERY_WORKER_SHUTDOWN_INTERVAL: "65m" @@ -84,6 +87,8 @@ services: MYSQL_PORT: 3306 REDIS_URI: redis://redis:6379/0 DJANGO_SETTINGS_MODULE: settings.hobby + OSS: "True" + CELERY_WORKER_QUEUE: "default,critical,long,slack,telegram,webhook,retry,celery" depends_on: mysql: condition: service_healthy From 51eda4786b6d69a0c28b659775bbfb21afa775ff Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 23:24:47 +0300 Subject: [PATCH 081/132] Hobby polish --- docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c15cef14..bf5c1d35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: engine: # TODO: change to the public image once it's public # image: ... - build: ../../engine + build: engine ports: - 8080:8080 command: > @@ -36,7 +36,7 @@ services: celery: # TODO: change to the public image once it's public - build: ../../engine + build: engine command: sh -c "./celery_with_exporter.sh" environment: BASE_URL: https://$DOMAIN @@ -70,7 +70,7 @@ services: condition: service_started oncall_db_migration: - build: ../../engine + build: engine command: python manage.py migrate --noinput environment: BASE_URL: https://$DOMAIN @@ -158,7 +158,7 @@ services: GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app GF_INSTALL_PLUGINS: grafana-oncall-app volumes: - - ../../grafana-plugin:/var/lib/grafana/plugins/grafana-plugin + - ./grafana-plugin:/var/lib/grafana/plugins/grafana-plugin depends_on: mysql_to_create_grafana_db: condition: service_completed_successfully From 7b385a87906f98d15b43ad56cdb5c9933a7ba2ac Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Thu, 9 Jun 2022 23:27:40 +0300 Subject: [PATCH 082/132] Refactor integrations --- .../alerts/tests/test_alert_group_renderer.py | 2 +- .../alerts/tests/test_default_templates.py | 2 +- .../metadata/configuration/amazon_sns.py | 99 ------------------- .../alertmanager.py | 0 engine/config_integrations/elastalert.py | 66 +++++++++++++ .../formatted_webhook.py | 0 .../grafana.py | 0 .../grafana_alerting.py | 0 .../heartbeat.py | 0 .../inbound_email.py | 0 engine/config_integrations/kapacitor.py | 65 ++++++++++++ .../maintenance.py | 0 .../manual.py | 0 .../slack_channel.py | 0 .../webhook.py | 0 engine/settings/base.py | 23 ++--- 16 files changed, 145 insertions(+), 112 deletions(-) delete mode 100644 engine/apps/integrations/metadata/configuration/amazon_sns.py rename engine/{apps/integrations/metadata/configuration => config_integrations}/alertmanager.py (100%) create mode 100644 engine/config_integrations/elastalert.py rename engine/{apps/integrations/metadata/configuration => config_integrations}/formatted_webhook.py (100%) rename engine/{apps/integrations/metadata/configuration => config_integrations}/grafana.py (100%) rename engine/{apps/integrations/metadata/configuration => config_integrations}/grafana_alerting.py (100%) rename engine/{apps/integrations/metadata/configuration => config_integrations}/heartbeat.py (100%) rename engine/{apps/integrations/metadata/configuration => config_integrations}/inbound_email.py (100%) create mode 100644 engine/config_integrations/kapacitor.py rename engine/{apps/integrations/metadata/configuration => config_integrations}/maintenance.py (100%) rename engine/{apps/integrations/metadata/configuration => config_integrations}/manual.py (100%) rename engine/{apps/integrations/metadata/configuration => config_integrations}/slack_channel.py (100%) rename engine/{apps/integrations/metadata/configuration => config_integrations}/webhook.py (100%) diff --git a/engine/apps/alerts/tests/test_alert_group_renderer.py b/engine/apps/alerts/tests/test_alert_group_renderer.py index 5253832e..aa7df113 100644 --- a/engine/apps/alerts/tests/test_alert_group_renderer.py +++ b/engine/apps/alerts/tests/test_alert_group_renderer.py @@ -2,7 +2,7 @@ import pytest from apps.alerts.incident_appearance.templaters import AlertSlackTemplater from apps.alerts.models import AlertGroup -from apps.integrations.metadata.configuration import grafana +from config_integrations import grafana @pytest.mark.django_db diff --git a/engine/apps/alerts/tests/test_default_templates.py b/engine/apps/alerts/tests/test_default_templates.py index 69288fb6..c5ff9342 100644 --- a/engine/apps/alerts/tests/test_default_templates.py +++ b/engine/apps/alerts/tests/test_default_templates.py @@ -10,7 +10,7 @@ from apps.alerts.incident_appearance.templaters import ( AlertWebTemplater, ) from apps.alerts.models import Alert, AlertReceiveChannel -from apps.integrations.metadata.configuration import grafana +from config_integrations import grafana from common.jinja_templater import jinja_template_env from common.utils import getattrd diff --git a/engine/apps/integrations/metadata/configuration/amazon_sns.py b/engine/apps/integrations/metadata/configuration/amazon_sns.py deleted file mode 100644 index 954542d0..00000000 --- a/engine/apps/integrations/metadata/configuration/amazon_sns.py +++ /dev/null @@ -1,99 +0,0 @@ -# Main -enabled = True -title = "Amazon SNS" -slug = "amazon_sns" -short_description = None -is_displayed_on_web = True -description = None -is_featured = False -is_able_to_autoresolve = True -is_demo_alert_enabled = True - -description = None - -# Default templates -slack_title = """\ -{% if payload|length == 0 -%} -{% set title = payload.get("AlarmName", "Alert") %} -{%- else -%} -{% set title = "Alert" %} -{%- endif %} - -*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ title }}>* via {{ integration_name }} -{% if source_link %} - (*<{{ source_link }}|source>*) -{%- endif %}""" - -slack_message = """\ -{% if payload|length == 1 and "message" in payload -%} -{{ payload.get("message", "Non-JSON payload received. Please make sure you publish monitoring Alarms to SNS, not logs: https://docs.amixr.io/#/integrations/amazon_sns") }} -{%- else -%} -*State* {{ payload.get("NewStateValue", "NO") }} -Region: {{ payload.get("Region", "Undefined") }} -_Description_: {{ payload.get("AlarmDescription", "Undefined") }} -{%- endif %} -""" - -slack_image_url = None - -web_title = """\ -{% if payload|length == 0 -%} -{{ payload.get("AlarmName", "Alert")}} -{%- else -%} -Alert -{%- endif %}""" - -web_message = """\ -{% if payload|length == 1 and "message" in payload -%} -{{ payload.get("message", "Non-JSON payload received. Please make sure you publish monitoring Alarms to SNS, not logs: https://docs.amixr.io/#/integrations/amazon_sns") }} -{%- else -%} -**State** {{ payload.get("NewStateValue", "NO") }} -Region: {{ payload.get("Region", "Undefined") }} -*Description*: {{ payload.get("AlarmDescription", "Undefined") }} -{%- endif %} -""" - -web_image_url = slack_image_url - -sms_title = web_title - -phone_call_title = web_title - -email_title = web_title - -email_message = "{{ payload|tojson_pretty }}" - -telegram_title = sms_title - -telegram_message = """\ -{% if payload|length == 1 and "message" in payload -%} -{{ payload.get("message", "Non-JSON payload received. Please make sure you publish monitoring Alarms to SNS, not logs: https://docs.amixr.io/#/integrations/amazon_sns") }} -{%- else -%} -State {{ payload.get("NewStateValue", "NO") }} -Region: {{ payload.get("Region", "Undefined") }} -Description: {{ payload.get("AlarmDescription", "Undefined") }} -{%- endif %} -""" - -telegram_image_url = slack_image_url - -source_link = """\ -{% if payload|length == 0 -%} -{% if payload.get("Trigger", {}).get("Namespace") == "AWS/ElasticBeanstalk" -%} -https://console.aws.amazon.com/elasticbeanstalk/home?region={{ payload.get("TopicArn").split(":")[3] }} -{%- else -%} -https://console.aws.amazon.com/cloudwatch//home?region={{ payload.get("TopicArn").split(":")[3] }} -{%- endif %} -{%- endif %}""" - -grouping_id = web_title - -resolve_condition = """\ -{{ payload.get("NewStateValue", "") == "OK" }} -""" - -acknowledge_condition = None - -group_verbose_name = web_title - -example_payload = {"foo": "bar"} diff --git a/engine/apps/integrations/metadata/configuration/alertmanager.py b/engine/config_integrations/alertmanager.py similarity index 100% rename from engine/apps/integrations/metadata/configuration/alertmanager.py rename to engine/config_integrations/alertmanager.py diff --git a/engine/config_integrations/elastalert.py b/engine/config_integrations/elastalert.py new file mode 100644 index 00000000..3ede0fb4 --- /dev/null +++ b/engine/config_integrations/elastalert.py @@ -0,0 +1,66 @@ +# Main +enabled = True +title = "Elastalert" +slug = "elastalert" +short_description = "Elastic" +is_displayed_on_web = True +description = None +is_featured = False +is_able_to_autoresolve = True +is_demo_alert_enabled = True + +description = None + +# Default templates +slack_title = """\ +*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} Incident>* via {{ integration_name }} +{% if source_link %} + (*<{{ source_link }}|source>*) +{%- endif %}""" + +slack_message = "```{{ payload|tojson_pretty }}```" + +slack_image_url = None + +web_title = "Incident" + +web_message = """\ +``` +{{ payload|tojson_pretty }} +``` +""" + +web_image_url = slack_image_url + +sms_title = web_title + +phone_call_title = sms_title + +email_title = web_title + +email_message = "{{ payload|tojson_pretty }}" + +telegram_title = sms_title + +telegram_message = "{{ payload|tojson_pretty }}" + +telegram_image_url = slack_image_url + +source_link = None + +grouping_id = '{{ payload.get("alert_uid", "")}}' + +resolve_condition = """\ +{%- if "is_amixr_heartbeat_restored" in payload -%} +{# We don't know the payload format from your integration. #} +{# The heartbeat alerts will go here so we check for our own key #} +{{ payload["is_amixr_heartbeat_restored"] }} +{%- else -%} +{{ payload.get("state", "").upper() == "OK" }}' +{%- endif %}""" + +acknowledge_condition = None + +group_verbose_name = "Incident" + +example_payload = {"message": "This alert was sent by user for the demonstration purposes"} diff --git a/engine/apps/integrations/metadata/configuration/formatted_webhook.py b/engine/config_integrations/formatted_webhook.py similarity index 100% rename from engine/apps/integrations/metadata/configuration/formatted_webhook.py rename to engine/config_integrations/formatted_webhook.py diff --git a/engine/apps/integrations/metadata/configuration/grafana.py b/engine/config_integrations/grafana.py similarity index 100% rename from engine/apps/integrations/metadata/configuration/grafana.py rename to engine/config_integrations/grafana.py diff --git a/engine/apps/integrations/metadata/configuration/grafana_alerting.py b/engine/config_integrations/grafana_alerting.py similarity index 100% rename from engine/apps/integrations/metadata/configuration/grafana_alerting.py rename to engine/config_integrations/grafana_alerting.py diff --git a/engine/apps/integrations/metadata/configuration/heartbeat.py b/engine/config_integrations/heartbeat.py similarity index 100% rename from engine/apps/integrations/metadata/configuration/heartbeat.py rename to engine/config_integrations/heartbeat.py diff --git a/engine/apps/integrations/metadata/configuration/inbound_email.py b/engine/config_integrations/inbound_email.py similarity index 100% rename from engine/apps/integrations/metadata/configuration/inbound_email.py rename to engine/config_integrations/inbound_email.py diff --git a/engine/config_integrations/kapacitor.py b/engine/config_integrations/kapacitor.py new file mode 100644 index 00000000..d5f013fe --- /dev/null +++ b/engine/config_integrations/kapacitor.py @@ -0,0 +1,65 @@ +# Main +enabled = True +title = "Kapacitor" +slug = "kapacitor" +short_description = "InfluxDB" +description = None +is_displayed_on_web = True +is_featured = False +is_able_to_autoresolve = True +is_demo_alert_enabled = True + +description = None + +# Default templates +slack_title = """\ +*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ payload.get("id", "Title undefined (check Slack Title Template)") }}>* via {{ integration_name }} +{% if source_link %} + (*<{{ source_link }}|source>*) +{%- endif %}""" + +slack_message = """\ +```{{ payload|tojson_pretty }}``` +""" + +slack_image_url = None + +web_title = '{{ payload.get("id", "Title undefined (check Web Title Template)") }}' + +web_message = """\ +``` +{{ payload|tojson_pretty }} +``` +""" + +web_image_url = slack_image_url + +sms_title = web_title + +phone_call_title = web_title + +email_title = web_title + +email_message = slack_message + +telegram_title = sms_title + +telegram_message = "{{ payload|tojson_pretty }}" + +telegram_image_url = slack_image_url + +source_link = None + +grouping_id = '{{ payload.get("id", "") }}' + +resolve_condition = '{{ payload.get("level", "").startswith("OK") }}' + +acknowledge_condition = None + +group_verbose_name = '{{ payload.get("id", "") }}' + +example_payload = { + "id": "TestAlert", + "message": "This alert was sent by user for the demonstration purposes", + "data": "{foo: bar}", +} diff --git a/engine/apps/integrations/metadata/configuration/maintenance.py b/engine/config_integrations/maintenance.py similarity index 100% rename from engine/apps/integrations/metadata/configuration/maintenance.py rename to engine/config_integrations/maintenance.py diff --git a/engine/apps/integrations/metadata/configuration/manual.py b/engine/config_integrations/manual.py similarity index 100% rename from engine/apps/integrations/metadata/configuration/manual.py rename to engine/config_integrations/manual.py diff --git a/engine/apps/integrations/metadata/configuration/slack_channel.py b/engine/config_integrations/slack_channel.py similarity index 100% rename from engine/apps/integrations/metadata/configuration/slack_channel.py rename to engine/config_integrations/slack_channel.py diff --git a/engine/apps/integrations/metadata/configuration/webhook.py b/engine/config_integrations/webhook.py similarity index 100% rename from engine/apps/integrations/metadata/configuration/webhook.py rename to engine/config_integrations/webhook.py diff --git a/engine/settings/base.py b/engine/settings/base.py index b2150a47..74dd1545 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -424,15 +424,16 @@ FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED = getenv_boolean("FEATURE_EXTRA_MESSAGI EXTRA_MESSAGING_BACKENDS = [] INSTALLED_ONCALL_INTEGRATIONS = [ - "apps.integrations.metadata.configuration.alertmanager", - "apps.integrations.metadata.configuration.grafana", - "apps.integrations.metadata.configuration.grafana_alerting", - "apps.integrations.metadata.configuration.formatted_webhook", - "apps.integrations.metadata.configuration.webhook", - "apps.integrations.metadata.configuration.amazon_sns", - "apps.integrations.metadata.configuration.heartbeat", - "apps.integrations.metadata.configuration.inbound_email", - "apps.integrations.metadata.configuration.maintenance", - "apps.integrations.metadata.configuration.manual", - "apps.integrations.metadata.configuration.slack_channel", + "config_integrations.alertmanager", + "config_integrations.grafana", + "config_integrations.grafana_alerting", + "config_integrations.formatted_webhook", + "config_integrations.webhook", + "config_integrations.kapacitor", + "config_integrations.elastalert", + "config_integrations.heartbeat", + "config_integrations.inbound_email", + "config_integrations.maintenance", + "config_integrations.manual", + "config_integrations.slack_channel", ] From 51f1e2f5aa894c85cee2f84c7424c5572532094e Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Thu, 9 Jun 2022 23:55:57 +0300 Subject: [PATCH 083/132] Hobby polish --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e9d1e7da..40caffa2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ We prepared multiple environments: [production](PRODUCTION.md), [developer](DEVE 1. Download docker-compose.yaml: ```bash -curl https://github.com/... -o docker-compose.yaml +curl https://github.com/grafana/oncall/blob/dev/docker-compose.yml -o docker-compose.yaml ``` 2. Set variables: From ac3a7f4df01e813ecb7ef0f89044982e5adaed8d Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Fri, 10 Jun 2022 00:05:28 +0300 Subject: [PATCH 084/132] Texts polishing for configuration page --- .../PluginConfigPage/PluginConfigPage.tsx | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index b83170a4..74c42005 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -261,31 +261,15 @@ export const PluginConfigPage = (props: Props) => {

1. Launch backend

- +

- Run production backend using{' '} - - this instructions at our GitHub + Run hobby, dev or production backend:{' '} + + getting started. - , - - Or run the local one: -

-              
-                 {
-                    openNotification('Grafana OnCall command copied');
-                  }}
-                >
-                  
-                {' '}
-                docker build -t grafana/amixr-all-in-one -f Dockerfile.all-in-one .
-              
-            
-
- +

+

2. Conect the backend and the plugin

{'Plugin <-> backend connection status:'}


From d201550989b45afa4c5f086ae644746a38881de5 Mon Sep 17 00:00:00 2001
From: Matvey Kukuy 
Date: Fri, 10 Jun 2022 00:11:14 +0300
Subject: [PATCH 085/132] Update README.md

---
 README.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md
index 40caffa2..7eb5198b 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ export DOMAIN=http://localhost
 export SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long
 export RABBITMQ_PASSWORD=rabbitmq_secret_pw
 export MYSQL_PASSWORD=mysql_secret_pw
-export COMPOSE_PROFILES=with_grafana
+export COMPOSE_PROFILES=with_grafana  # Comment this line if you want to use existing grafana
 export GRAFANA_USER=admin
 export GRAFANA_PASSWORD=admin
 ```
@@ -39,7 +39,7 @@ docker-compose -f docker-compose.yml up --build -d
 docker-compose -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override
 ```
 
-5. Go to http://localhost:3000/plugins/grafana-oncall-app and connect _OnCall plugin_ with _OnCall backend_:
+5. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app) (or find OnCall plugin in configuration->plugins) and connect OnCall _plugin_ with OnCall _backend_:
 ```
 Invite token: ^^^ from the previous step.
 OnCall backend URL: http://engine:8080

From e7bbbfac8442dc1ba5787d5e8f46f443c40cf43a Mon Sep 17 00:00:00 2001
From: Matvey Kukuy 
Date: Fri, 10 Jun 2022 00:18:17 +0300
Subject: [PATCH 086/132] docker-compose polish

---
 .dockerignore |  3 ++-
 .gitignore    |  1 +
 README.md     | 18 +++++++++---------
 3 files changed, 12 insertions(+), 10 deletions(-)

diff --git a/.dockerignore b/.dockerignore
index e541b3d4..6561a0ad 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,4 +5,5 @@ frontend/node_modules
 frontend/build
 package-lock.json
 ./engine/extensions
-.env
\ No newline at end of file
+.env
+.env-hobby
diff --git a/.gitignore b/.gitignore
index ae81aab5..d5099b36 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
 *.pyc
 venv
 .env
+.env-hobby
 .vscode
 dump.rdb
 .idea
diff --git a/README.md b/README.md
index 7eb5198b..91900bc8 100644
--- a/README.md
+++ b/README.md
@@ -20,23 +20,23 @@ curl https://github.com/grafana/oncall/blob/dev/docker-compose.yml -o docker-com
 
 2. Set variables:
 ```bash
-export DOMAIN=http://localhost
-export SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long
-export RABBITMQ_PASSWORD=rabbitmq_secret_pw
-export MYSQL_PASSWORD=mysql_secret_pw
-export COMPOSE_PROFILES=with_grafana  # Comment this line if you want to use existing grafana
-export GRAFANA_USER=admin
-export GRAFANA_PASSWORD=admin
+echo "DOMAIN=http://localhost
+SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long
+RABBITMQ_PASSWORD=rabbitmq_secret_pw
+MYSQL_PASSWORD=mysql_secret_pw
+COMPOSE_PROFILES=with_grafana  # Remove this line if you want to use existing grafana
+GRAFANA_USER=admin
+GRAFANA_PASSWORD=admin" > .env_hobby
 ```
 
 3. Launch services:
 ```bash
-docker-compose -f docker-compose.yml up --build -d
+docker-compose --env-file .env_hobby -f docker-compose.yml up --build -d
 ```
 
 4. Issue one-time invite token:
 ```bash
-docker-compose -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override
+docker-compose --env-file .env_hobby -f docker-compose.yml run engine python manage.py issue_invite_for_the_frontend --override
 ```
 
 5. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app) (or find OnCall plugin in configuration->plugins) and connect OnCall _plugin_ with OnCall _backend_:

From 43e9e6843d1330785a64383338ddb9df9e8c5743 Mon Sep 17 00:00:00 2001
From: Matvey Kukuy 
Date: Fri, 10 Jun 2022 17:32:58 +0300
Subject: [PATCH 087/132] Developer setup update

---
 DEVELOPER.md                 |  5 +----
 docker-compose-developer.yml |  6 +++---
 engine/settings/dev.py       | 21 ++++++++++++++++++---
 3 files changed, 22 insertions(+), 10 deletions(-)

diff --git a/DEVELOPER.md b/DEVELOPER.md
index 8d8ad75b..551a4592 100644
--- a/DEVELOPER.md
+++ b/DEVELOPER.md
@@ -53,9 +53,6 @@ export $(grep -v '^#' .env | xargs -0)
 # Hint: there is a known issue with uwsgi. It's not used in the local dev environment. Feel free to comment it in `engine/requirements.txt`.
 cd engine && pip install -r requirements.txt
 
-# Create folder for database
-mkdir sqlite_data
-
 # Migrate the DB:
 python manage.py migrate
 
@@ -107,7 +104,7 @@ python manage.py issue_invite_for_the_frontend --override
 OnCall API URL: 
 http://host.docker.internal:8000
 
-OnCall Invitation Token (Single use token to connect Grafana instance):
+Invitation Token (Single use token to connect Grafana instance):
 Response from the invite generator command (check above)
 
 Grafana URL (URL OnCall will use to talk to Grafana instance):
diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml
index 08ce52fc..da915f78 100644
--- a/docker-compose-developer.yml
+++ b/docker-compose-developer.yml
@@ -12,7 +12,7 @@ services:
     ports:
       - 3306:3306
     environment:
-      MYSQL_ROOT_PASSWORD: local_dev_pwd
+      MYSQL_ROOT_PASSWORD: empty
       MYSQL_DATABASE: oncall_local_dev
     healthcheck:
       test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
@@ -42,7 +42,7 @@ services:
   mysql-to-create-grafana-db:
     image: mariadb:10.2
     platform: linux/x86_64
-    command: bash -c "mysql -h mysql -uroot -plocal_dev_pwd -e 'CREATE DATABASE IF NOT EXISTS grafana CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'"
+    command: bash -c "mysql -h mysql -uroot -pempty -e 'CREATE DATABASE IF NOT EXISTS grafana CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'"
     depends_on:
       mysql:
         condition: service_healthy
@@ -56,7 +56,7 @@ services:
       GF_DATABASE_TYPE: mysql
       GF_DATABASE_HOST: mysql
       GF_DATABASE_USER: root
-      GF_DATABASE_PASSWORD: local_dev_pwd
+      GF_DATABASE_PASSWORD: empty
       GF_SECURITY_ADMIN_USER: oncall
       GF_SECURITY_ADMIN_PASSWORD: oncall
       GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app
diff --git a/engine/settings/dev.py b/engine/settings/dev.py
index aff8ca9d..ba1b3022 100644
--- a/engine/settings/dev.py
+++ b/engine/settings/dev.py
@@ -10,14 +10,29 @@ MIRAGE_SECRET_KEY = os.environ.get(
 )
 MIRAGE_CIPHER_IV = os.environ.get("MIRAGE_CIPHER_IV", "tZZa+60zTZO2NRcS")
 
-# Primary database must have the name "default"
+# Workaround to use pymysql instead of mysqlclient
+import pymysql
+
+pymysql.install_as_MySQLdb()
+
 DATABASES = {
     "default": {
-        "ENGINE": "django.db.backends.sqlite3",
-        "NAME": os.path.join(BASE_DIR, "sqlite_data/db.sqlite3"),  # noqa
+        "ENGINE": "django.db.backends.mysql",
+        "NAME": os.environ.get("MYSQL_DB_NAME", "oncall_local_dev"),
+        "USER": os.environ.get("MYSQL_USER", "root"),
+        "PASSWORD": os.environ.get("MYSQL_PASSWORD"),
+        "HOST": os.environ.get("MYSQL_HOST", "127.0.0.1"),
+        "PORT": os.environ.get("MYSQL_PORT", "3306"),
+        "OPTIONS": {
+            "charset": "utf8mb4",
+            "connect_timeout": 1,
+        },
     },
 }
 
+os.environ.setdefault("OSS", "True")
+INSTALLED_APPS += ["apps.oss_installation"]  # noqa
+
 TESTING = "pytest" in sys.modules or "unittest" in sys.modules
 
 READONLY_DATABASES = {}

From 7dbc8dcdc64da8be77040a8962930a16a161c7ff Mon Sep 17 00:00:00 2001
From: Matvey Kukuy 
Date: Fri, 10 Jun 2022 17:51:50 +0300
Subject: [PATCH 088/132] Developer setup update

---
 engine/settings/dev.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/engine/settings/dev.py b/engine/settings/dev.py
index ba1b3022..6c1066f4 100644
--- a/engine/settings/dev.py
+++ b/engine/settings/dev.py
@@ -1,6 +1,9 @@
 import os
 import sys
 
+# Workaround to use pymysql instead of mysqlclient
+import pymysql
+
 from .base import *  # noqa
 
 SECRET_KEY = os.environ.get("SECRET_KEY", "osMsNM0PqlRHBlUvqmeJ7+ldU3IUETCrY9TrmiViaSmInBHolr1WUlS0OFS4AHrnnkp1vp9S9z1")
@@ -10,9 +13,6 @@ MIRAGE_SECRET_KEY = os.environ.get(
 )
 MIRAGE_CIPHER_IV = os.environ.get("MIRAGE_CIPHER_IV", "tZZa+60zTZO2NRcS")
 
-# Workaround to use pymysql instead of mysqlclient
-import pymysql
-
 pymysql.install_as_MySQLdb()
 
 DATABASES = {

From 1070fc0bd351eb9d9bf5983c9cb979d3560f25a0 Mon Sep 17 00:00:00 2001
From: Yulia Shanyrova 
Date: Fri, 10 Jun 2022 20:05:36 +0200
Subject: [PATCH 089/132] Grafana URL saved at localstorage, confirmation
 window deleted

---
 .../PluginConfigPage.module.css               |  1 +
 .../PluginConfigPage/PluginConfigPage.tsx     | 44 ++-----------------
 2 files changed, 5 insertions(+), 40 deletions(-)

diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.module.css b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.module.css
index 2cca3ca5..d0eafdf8 100644
--- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.module.css
+++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.module.css
@@ -4,4 +4,5 @@
 
 .info-block {
   margin-bottom: 24px;
+  margin-top: 24px;
 }
diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx
index f77c7dc1..a76af84c 100644
--- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx
+++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx
@@ -35,23 +35,20 @@ const cx = cn.bind(styles);
 interface Props extends PluginConfigPageProps> {}
 
 export const PluginConfigPage = (props: Props) => {
+  const grafanaUrlDefault = getItem('grafanaUrl') || window.location.origin;
   const { plugin } = props;
   const [onCallApiUrl, setOnCallApiUrl] = useState(getItem('onCallApiUrl'));
   const [onCallInvitationToken, setOnCallInvitationToken] = useState();
-  const [grafanaUrl, setGrafanaUrl] = useState(window.location.origin);
+  const [grafanaUrl, setGrafanaUrl] = useState(grafanaUrlDefault);
   const [pluginConfigLoading, setPluginConfigLoading] = useState(true);
   const [pluginStatusOk, setPluginStatusOk] = useState();
   const [pluginStatusMessage, setPluginStatusMessage] = useState();
   const [isSelfHostedInstall, setIsSelfHostedInstall] = useState(true);
   const [retrySync, setRetrySync] = useState(false);
-  const [showConfirmationModal, setShowConfirmationModal] = useState(false);
 
-  const configurePlugin = () => {
-    setShowConfirmationModal(true);
-  };
   const setupPlugin = useCallback(async () => {
     setItem('onCallApiUrl', onCallApiUrl);
-    setShowConfirmationModal(false);
+    setItem('grafanaUrl', grafanaUrl);
     await getBackendSrv().post(`/api/plugins/grafana-oncall-app/settings`, {
       enabled: true,
       pinned: true,
@@ -258,21 +255,6 @@ export const PluginConfigPage = (props: Props) => {
                 getting started.
               
             
-
-            Or run the local one:
-            
-              
-                 {
-                    openNotification('Grafana OnCall command copied');
-                  }}
-                >
-                  
-                {' '}
-                docker build -t grafana/amixr-all-in-one -f Dockerfile.all-in-one .
-              
-            
@@ -329,32 +311,14 @@ Seek for such a line: “Your invite token: <> , use it in the Graf - {/* */} - {/* */} - {showConfirmationModal && ( - setShowConfirmationModal(false)} - > - - - - - - )} )}
From 78f7d960d68367a3687ecf25058db17eec0efbe0 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Fri, 10 Jun 2022 22:58:46 +0300 Subject: [PATCH 090/132] Polishing... --- .gitignore | 2 +- docker-compose-developer.yml | 2 +- docker-compose.yml | 2 +- engine/scripts/start_all_in_one.sh | 34 ------------------- .../PluginConfigPage/PluginConfigPage.tsx | 2 +- grafana-plugin/src/pages/cloud/CloudPage.tsx | 4 +-- 6 files changed, 6 insertions(+), 40 deletions(-) delete mode 100644 engine/scripts/start_all_in_one.sh diff --git a/.gitignore b/.gitignore index d5099b36..b00b88a2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ *.pyc venv .env -.env-hobby +.env_hobby .vscode dump.rdb .idea diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index da915f78..71280b77 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -48,7 +48,7 @@ services: condition: service_healthy grafana: - image: "grafana/grafana:8.5.5" + image: "grafana/grafana:9.0.0-beta3" restart: always mem_limit: 500m cpus: 0.5 diff --git a/docker-compose.yml b/docker-compose.yml index bf5c1d35..868e309f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -143,7 +143,7 @@ services: - with_grafana grafana: - image: "grafana/grafana:8.3.2" + image: "grafana/grafana:9.0.0-beta3" mem_limit: 500m ports: - 3000:3000 diff --git a/engine/scripts/start_all_in_one.sh b/engine/scripts/start_all_in_one.sh deleted file mode 100644 index f4a64e39..00000000 --- a/engine/scripts/start_all_in_one.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash - -export DJANGO_SETTINGS_MODULE=settings.all_in_one - -generate_value_if_not_exist () -{ - if [ ! -f /etc/app/secret_data/$1 ]; then - touch /etc/app/secret_data/$1 - base64 /dev/urandom | head -c $2 > /etc/app/secret_data/$1 -fi -export $1=$(cat /etc/app/secret_data/$1) -} - -generate_value_if_not_exist SECRET_KEY 75 - -generate_value_if_not_exist MIRAGE_SECRET_KEY 75 -generate_value_if_not_exist MIRAGE_CIPHER_IV 16 - -export BASE_URL=http://localhost:8000 - -echo "Starting redis in the background" -# Redis will dump the changes to the volume every 60 seconds if at least 1 key changed -redis-server --daemonize yes --save 60 1 --dir /etc/app/redis_data/ -echo "Running migrations" -python manage.py migrate - -echo "Start celery" -python manage.py start_celery & - -# Postponing token issuing to make sure it's the last record in the console. -bash -c 'sleep 10; python manage.py issue_invite_for_the_frontend --override' & - -echo "Starting server" -python manage.py runserver 0.0.0.0:8000 --noreload diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index 74c42005..92725630 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -299,7 +299,7 @@ http://localhost:8000 > - + {/* */} diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index c02cf162..02ae2493 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -289,7 +289,7 @@ const CloudPage = observer((props: CloudPageProps) => { ) : ( )} @@ -351,7 +351,7 @@ const CloudPage = observer((props: CloudPageProps) => { SMS and phone call notifications
- Users matched between OSS and Cloud OnCall currently unavialable. + Users matched between OSS and Cloud OnCall currently unavailable.
From e9b9d50a794e64e974d59bd4e9d81f9e436b68c2 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Mon, 13 Jun 2022 09:59:22 +0300 Subject: [PATCH 091/132] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 91900bc8..fb44183c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -Developer-friendly, incident response management with brilliant Slack integration. +Developer-friendly, incident response with brilliant Slack integration. From 570b832225e07595ace359fcd7acd8c37aa3bc7b Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Mon, 13 Jun 2022 11:27:41 +0300 Subject: [PATCH 092/132] Fix linting --- engine/apps/alerts/tests/test_default_templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/alerts/tests/test_default_templates.py b/engine/apps/alerts/tests/test_default_templates.py index c5ff9342..63cfd0b8 100644 --- a/engine/apps/alerts/tests/test_default_templates.py +++ b/engine/apps/alerts/tests/test_default_templates.py @@ -10,9 +10,9 @@ from apps.alerts.incident_appearance.templaters import ( AlertWebTemplater, ) from apps.alerts.models import Alert, AlertReceiveChannel -from config_integrations import grafana from common.jinja_templater import jinja_template_env from common.utils import getattrd +from config_integrations import grafana @pytest.mark.django_db From f81227a2d1858d3918ea359557f0f3efafebf667 Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Mon, 13 Jun 2022 11:43:24 +0300 Subject: [PATCH 093/132] Fix linting --- engine/apps/alerts/models/channel_filter.py | 15 +-------------- .../apps/slack/scenarios/alertgroup_appearance.py | 6 +++++- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/engine/apps/alerts/models/channel_filter.py b/engine/apps/alerts/models/channel_filter.py index b1f1dae2..fb369088 100644 --- a/engine/apps/alerts/models/channel_filter.py +++ b/engine/apps/alerts/models/channel_filter.py @@ -113,20 +113,7 @@ class ChannelFilter(OrderedModel): return satisfied_filter def is_satisfying(self, raw_request_data, title, message=None): - AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel") - - return ( - self.is_default - or self.check_filter(json.dumps(raw_request_data)) - or self.check_filter(str(title)) - or - # Special case for Amazon SNS - ( - self.check_filter(str(message)) - if self.alert_receive_channel.integration == AlertReceiveChannel.INTEGRATION_AMAZON_SNS - else False - ) - ) + return self.is_default or self.check_filter(json.dumps(raw_request_data)) or self.check_filter(str(title)) def check_filter(self, value): return re.search(self.filtering_term, value) diff --git a/engine/apps/slack/scenarios/alertgroup_appearance.py b/engine/apps/slack/scenarios/alertgroup_appearance.py index edf0a704..1ccba05f 100644 --- a/engine/apps/slack/scenarios/alertgroup_appearance.py +++ b/engine/apps/slack/scenarios/alertgroup_appearance.py @@ -56,7 +56,11 @@ class OpenAlertAppearanceDialogStep( raw_request_data = json.dumps(alert_group.alerts.first().raw_request_data, sort_keys=True, indent=4) # This is a special case for amazon sns notifications in str format CHEKED - if alert_group.channel.integration == AlertReceiveChannel.INTEGRATION_AMAZON_SNS and raw_request_data == "{}": + if ( + AlertReceiveChannel.INTEGRATION_AMAZON_SNS is not None + and alert_group.channel.integration == AlertReceiveChannel.INTEGRATION_AMAZON_SNS + and raw_request_data == "{}" + ): raw_request_data = alert_group.alerts.first().message raw_request_data_chunks = [ From eb87a57b43f8a4864baab36a9af3339743653520 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Mon, 13 Jun 2022 12:11:38 +0300 Subject: [PATCH 094/132] Open Source page --- docs/Makefile | 2 +- docs/README.md | 2 +- docs/sources/open-source.md | 174 ++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 docs/sources/open-source.md diff --git a/docs/Makefile b/docs/Makefile index 5ddacacf..e66f1c1c 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,5 +1,5 @@ IMAGE = grafana/docs-base:latest -CONTENT_PATH = /hugo/content/docs/amixr/latest +CONTENT_PATH = /hugo/content/docs/oncall/latest PORT = 3002:3002 .PHONY: pull diff --git a/docs/README.md b/docs/README.md index 8d702ceb..b6a557c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,5 +4,5 @@ Source for documentation at https://grafana.com/docs/amixr/ ## Preview the website -Run `make docs`. This launches a preview of the website with the current grafana docs at `http://localhost:3002/docs/amixr/` which will refresh automatically when changes are made to content in the `sources` directory. +Run `make docs`. This launches a preview of the website with the current grafana docs at `http://localhost:3002/docs/oncall/latest/` which will refresh automatically when changes are made to content in the `sources` directory. Make sure Docker is running. diff --git a/docs/sources/open-source.md b/docs/sources/open-source.md new file mode 100644 index 00000000..db0dacf5 --- /dev/null +++ b/docs/sources/open-source.md @@ -0,0 +1,174 @@ +--- +aliases: + - /docs/grafana-cloud/oncall/open-source/ + - /docs/oncall/latest/open-source/ +keywords: + - Open Source +title: Open Source +weight: 100 +--- + +# Open Source + +We prepared three environments for OSS users: +- **Hobby** environment for local usage & playing around: [README.md](https://github.com/grafana/oncall#getting-started). +- **Development** environment for contributors: [DEVELOPER.md](https://github.com/grafana/oncall/blob/dev/DEVELOPER.md) +- **Production** environment for reliable cloud installation using Helm: #production + +## Slack Setup + +Grafana OnCall Slack integration use most of the features Slack API provides. +- Subscription on Slack events requires OnCall to be externally available and provide https endpoint. +- You will need to register new Slack App. + +1. Make sure your OnCall is up and running. + +2. You need OnCall to be accessible through https. For development purposes we suggest using [localtunnel](https://github.com/localtunnel/localtunnel). For production purposes please consider setting up proper web server with HTTPS termination. For localtunnel: +```bash +# Choose the unique prefix instead of pretty-turkey-83 +# Localtunnel will generate an url, e.g. https://pretty-turkey-83.loca.lt +# it is referred as below +lt --port 8000 -s pretty-turkey-83 --print-requests +``` + +2. [Create a Slack Workspace](https://slack.com/create) for development, or use your company workspace. + +3. Go to https://api.slack.com/apps and click Create New App button + +4. Select `From an app manifest` option and choose the right workspace + +5. Copy and paste the following block with the correct and fields + +
+ Click to expand! + + ```yaml + _metadata: + major_version: 1 + minor_version: 1 + display_information: + name: + features: + app_home: + home_tab_enabled: true + messages_tab_enabled: true + messages_tab_read_only_enabled: false + bot_user: + display_name: + always_online: true + shortcuts: + - name: Create a new incident + type: message + callback_id: incident_create + description: Creates a new OnCall incident + - name: Add to postmortem + type: message + callback_id: add_postmortem + description: Add this message to postmortem + slash_commands: + - command: /oncall + url: /slack/interactive_api_endpoint/ + description: oncall + should_escape: false + oauth_config: + redirect_urls: + - /api/internal/v1/complete/slack-install-free/ + - /api/internal/v1/complete/slack-login/ + scopes: + user: + - channels:read + - chat:write + - identify + - users.profile:read + bot: + - app_mentions:read + - channels:history + - channels:read + - chat:write + - chat:write.customize + - chat:write.public + - commands + - files:write + - groups:history + - groups:read + - im:history + - im:read + - im:write + - mpim:history + - mpim:read + - mpim:write + - reactions:write + - team:read + - usergroups:read + - usergroups:write + - users.profile:read + - users:read + - users:read.email + - users:write + settings: + event_subscriptions: + request_url: /slack/event_api_endpoint/ + bot_events: + - app_home_opened + - app_mention + - channel_archive + - channel_created + - channel_deleted + - channel_rename + - channel_unarchive + - member_joined_channel + - message.channels + - message.im + - subteam_created + - subteam_members_changed + - subteam_updated + - user_change + interactivity: + is_enabled: true + request_url: /slack/interactive_api_endpoint/ + org_deploy_enabled: false + socket_mode_enabled: false + ``` +
+ +6. Click `Install to workspace` button to generate the credentials + +7. Populate the environment with variables related to Slack. + + Go to your OnCall plugin -> Env Variables and set: + ``` + SLACK_CLIENT_OAUTH_ID = Basic Information -> App Credentials -> Client ID + SLACK_CLIENT_OAUTH_SECRET = Basic Information -> App Credentials -> Client Secret + SLACK_API_TOKEN = OAuth & Permissions -> Bot User OAuth Token + SLACK_INSTALL_RETURN_REDIRECT_HOST = https://pretty-turkey-83.loca.lt + ``` + +8. Set BASE_URL Env variable through web interface or edit `grafana-plugin/grafana-plugin.yml` to set `onCallApiUrl` fields with publicly available url: + ``` + onCallApiUrl: https://pretty-turkey-83.loca.lt + ``` + +9. For dev environment only: Edit grafana-plugin/src/plugin.json to add `Bypass-Tunnel-Reminder` header section for all existing routes + > this headers required for the local development only, otherwise localtunnel blocks requests from grafana plugin + + ``` + { + "path": ..., + ... + "headers": [ + ... + { + "name": "Bypass-Tunnel-Reminder", + "content": "True" + } + ] + }, + ``` +10. Rebuild the plugin + ``` + yarn watch + ``` +11. Restart grafana instance + +12. All set! Go to Slack and check if your application is functional. + From e5b55507d920a34d9b60c64770c9bee88f98022a Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Mon, 13 Jun 2022 13:37:48 +0300 Subject: [PATCH 095/132] Fix typo in templates --- engine/apps/integrations/metadata/configuration/webhook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/apps/integrations/metadata/configuration/webhook.py b/engine/apps/integrations/metadata/configuration/webhook.py index ea18fab7..113efc56 100644 --- a/engine/apps/integrations/metadata/configuration/webhook.py +++ b/engine/apps/integrations/metadata/configuration/webhook.py @@ -56,7 +56,7 @@ resolve_condition = """\ {# The heartbeat alerts will go here so we check for our own key #} {{ payload["is_amixr_heartbeat_restored"] }} {%- else -%} -{{ payload.get("state", "").upper() == "OK" }}' +{{ payload.get("state", "").upper() == "OK" }} {%- endif %}""" acknowledge_condition = None From b3add5c9b890e42a5a4ecdb02b6f719809f67c1c Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Mon, 13 Jun 2022 13:38:40 +0300 Subject: [PATCH 096/132] Fix typo in template --- engine/config_integrations/elastalert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/config_integrations/elastalert.py b/engine/config_integrations/elastalert.py index 3ede0fb4..90e9bfcc 100644 --- a/engine/config_integrations/elastalert.py +++ b/engine/config_integrations/elastalert.py @@ -56,7 +56,7 @@ resolve_condition = """\ {# The heartbeat alerts will go here so we check for our own key #} {{ payload["is_amixr_heartbeat_restored"] }} {%- else -%} -{{ payload.get("state", "").upper() == "OK" }}' +{{ payload.get("state", "").upper() == "OK" }} {%- endif %}""" acknowledge_condition = None From 48bfe86d62d7dc60cf6e3eb0033f38a77ee2e118 Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 13 Jun 2022 16:29:08 +0400 Subject: [PATCH 097/132] Add cloud connection statuses on user page (#34) * Add cloud connection statuses on user page * Add fixes for oncall hobby docker-compose installation * Fix for links and for cloud user status at User settings * Delete cloud api token on cloud disconnect * Merge branch 'dev' into cloud_connection_statuses_on_user_page * Fix cloud statuses for users * Fix usagestats service * Fix phone verification message in users table * added request after syncing user * Add endpoint to create CloudHeartbeat and polish code * Fix imports * Check token and heartbeat setting in setup_hertbeat_integration * Add macthed_users_count in cloud users * Sync users on token change * Fix query param * Fix tests * Heartbit button logic, tab width fix, coount users fix * Solve problem of existent cloud heartbeat integration * Solve problem of existent cloud heartbeat integration 2 * Solve problem of existent cloud heartbeat integration 3 * fix build * build fix, styles for env variables description Co-authored-by: Ildar Iskhakov Co-authored-by: Yulia Shanyrova --- engine/apps/api/serializers/user.py | 14 ++ engine/apps/api/tests/test_user.py | 3 + engine/apps/api/views/live_setting.py | 7 +- engine/apps/api/views/user.py | 55 +++++- engine/apps/base/models/live_setting.py | 2 +- .../apps/oss_installation/cloud_heartbeat.py | 110 ++++++++++++ .../serializers/cloud_user.py | 23 +-- engine/apps/oss_installation/tasks.py | 94 ++-------- engine/apps/oss_installation/urls.py | 3 +- engine/apps/oss_installation/usage_stats.py | 7 +- engine/apps/oss_installation/utils.py | 19 ++ .../apps/oss_installation/views/__init__.py | 1 + .../views/cloud_connection.py | 17 +- .../oss_installation/views/cloud_heartbeat.py | 27 +++ .../oss_installation/views/cloud_users.py | 37 ++-- engine/apps/public_api/views/integrations.py | 4 + engine/settings/all_in_one.py | 25 --- engine/settings/base.py | 28 ++- engine/settings/ci-test.py | 2 - engine/settings/hobby.py | 19 -- .../components/Policy/NotificationPolicy.tsx | 22 ++- .../PersonalNotificationSettings.tsx | 9 +- .../parts/connectors/PhoneConnector.tsx | 101 ++++++++--- .../parts/connectors/index.module.css | 4 + .../CloudPhoneSettings/CloudPhoneSettings.tsx | 5 +- grafana-plugin/src/index.css | 4 +- grafana-plugin/src/models/base_store.ts | 3 +- grafana-plugin/src/models/cloud/cloud.ts | 14 +- grafana-plugin/src/models/user/user.types.ts | 1 + .../src/pages/cloud/CloudPage.module.css | 3 +- grafana-plugin/src/pages/cloud/CloudPage.tsx | 163 ++++++++++-------- .../livesettings/LiveSettings.module.css | 4 + .../pages/livesettings/LiveSettingsPage.tsx | 1 + .../src/pages/users/Users.module.css | 19 ++ grafana-plugin/src/pages/users/Users.tsx | 35 +++- 35 files changed, 590 insertions(+), 295 deletions(-) create mode 100644 engine/apps/oss_installation/cloud_heartbeat.py create mode 100644 engine/apps/oss_installation/views/cloud_heartbeat.py diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index e9ec91b2..db0db0ed 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -1,9 +1,12 @@ +from django.conf import settings from rest_framework import serializers from apps.api.serializers.telegram import TelegramToUserConnectorSerializer from apps.base.constants import ADMIN_PERMISSIONS, ALL_ROLES_PERMISSIONS, EDITOR_PERMISSIONS from apps.base.messaging import get_messaging_backends from apps.base.models import UserNotificationPolicy +from apps.base.utils import live_settings +from apps.oss_installation.utils import cloud_user_identity_status from apps.twilioapp.utils import check_phone_number_is_valid from apps.user_management.models import User from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField @@ -30,6 +33,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): permissions = serializers.SerializerMethodField() notification_chain_verbal = serializers.SerializerMethodField() + cloud_connection_status = serializers.SerializerMethodField() SELECT_RELATED = ["telegram_verification_code", "telegram_connection", "organization", "slack_user_identity"] @@ -50,6 +54,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): "messaging_backends", "permissions", "notification_chain_verbal", + "cloud_connection_status", ] read_only_fields = [ "email", @@ -88,6 +93,15 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin): default, important = UserNotificationPolicy.get_short_verbals_for_user(user=obj) return {"default": " - ".join(default), "important": " - ".join(important)} + def get_cloud_connection_status(self, obj): + if settings.OSS_INSTALLATION and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + connector = self.context.get("connector", None) + identities = self.context.get("cloud_identities", {}) + identity = identities.get(obj.email, None) + status, _ = cloud_user_identity_status(connector, identity) + return status + return None + class UserHiddenFieldsSerializer(UserSerializer): available_for_all_roles_fields = [ diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index 5731ed17..dd23feb5 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -75,6 +75,7 @@ def test_update_user_cant_change_email_and_username( "user": admin.username, } }, + "cloud_connection_status": 0, "permissions": ADMIN_PERMISSIONS, "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, @@ -124,6 +125,7 @@ def test_list_users( "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": admin.avatar_url, + "cloud_connection_status": 0, }, { "pk": editor.public_primary_key, @@ -144,6 +146,7 @@ def test_list_users( "notification_chain_verbal": {"default": "", "important": ""}, "slack_user_identity": None, "avatar": editor.avatar_url, + "cloud_connection_status": 0, }, ], } diff --git a/engine/apps/api/views/live_setting.py b/engine/apps/api/views/live_setting.py index 80dbd6a7..1718bd15 100644 --- a/engine/apps/api/views/live_setting.py +++ b/engine/apps/api/views/live_setting.py @@ -12,6 +12,7 @@ from apps.api.serializers.live_setting import LiveSettingSerializer from apps.auth_token.auth import PluginAuthentication from apps.base.models import LiveSetting from apps.base.utils import live_settings +from apps.oss_installation.tasks import sync_users_with_cloud from apps.slack.tasks import unpopulate_slack_user_identities from apps.telegram.client import TelegramClient from apps.telegram.tasks import register_telegram_webhook @@ -41,8 +42,10 @@ class LiveSettingViewSet(PublicPrimaryKeyMixin, viewsets.ModelViewSet): def perform_update(self, serializer): new_value = serializer.validated_data["value"] self._update_hook(new_value) - - super().perform_update(serializer) + instance = serializer.save() + sync_users = self.request.query_params.get("sync_users", "true") == "true" + if instance.name == "GRAFANA_CLOUD_ONCALL_TOKEN" and sync_users: + sync_users_with_cloud.apply_async() def perform_destroy(self, instance): new_value = instance.default_value diff --git a/engine/apps/api/views/user.py b/engine/apps/api/views/user.py index ee0a75de..e7d20a32 100644 --- a/engine/apps/api/views/user.py +++ b/engine/apps/api/views/user.py @@ -34,6 +34,7 @@ from apps.auth_token.models import UserScheduleExportAuthToken from apps.auth_token.models.mobile_app_auth_token import MobileAppAuthToken from apps.auth_token.models.mobile_app_verification_token import MobileAppVerificationToken from apps.base.messaging import get_messaging_backend_from_id +from apps.base.utils import live_settings from apps.telegram.client import TelegramClient from apps.telegram.models import TelegramVerificationCode from apps.twilioapp.phone_manager import PhoneManager @@ -56,7 +57,19 @@ class CurrentUserView(APIView): permission_classes = (IsAuthenticated,) def get(self, request): - serializer = UserSerializer(request.user, context={"request": self.request}) + context = {"request": self.request, "format": self.format_kwarg, "view": self} + + if settings.OSS_INSTALLATION and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + from apps.oss_installation.models import CloudConnector, CloudUserIdentity + + connector = CloudConnector.objects.first() + if connector is not None: + cloud_identities = list(CloudUserIdentity.objects.filter(email__in=[request.user.email])) + cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} + context["cloud_identities"] = cloud_identities + context["connector"] = connector + + serializer = UserSerializer(request.user, context=context) return Response(serializer.data) def put(self, request): @@ -179,6 +192,46 @@ class UserView( return queryset.order_by("id") + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + context = {"request": self.request, "format": self.format_kwarg, "view": self} + if settings.OSS_INSTALLATION: + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + from apps.oss_installation.models import CloudConnector, CloudUserIdentity + + connector = CloudConnector.objects.first() + if connector is not None: + emails = list(queryset.values_list("email", flat=True)) + cloud_identities = list(CloudUserIdentity.objects.filter(email__in=emails)) + cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} + context["cloud_identities"] = cloud_identities + context["connector"] = connector + serializer = self.get_serializer(page, many=True, context=context) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + def retrieve(self, request, *args, **kwargs): + context = {"request": self.request, "format": self.format_kwarg, "view": self} + instance = self.get_object() + + if settings.OSS_INSTALLATION and live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + from apps.oss_installation.models import CloudConnector, CloudUserIdentity + + connector = CloudConnector.objects.first() + if connector is not None: + cloud_identities = list(CloudUserIdentity.objects.filter(email__in=[instance.email])) + cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} + context["cloud_identities"] = cloud_identities + context["connector"] = connector + + serializer = self.get_serializer(instance, context=context) + return Response(serializer.data) + def current(self, request): serializer = UserSerializer(self.get_queryset().get(pk=self.request.user.pk)) return Response(serializer.data) diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index ca3331de..a4594b59 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -103,7 +103,7 @@ class LiveSetting(models.Model): "SEND_ANONYMOUS_USAGE_STATS": ( "Grafana OnCall will send anonymous, but uniquely-identifiable usage analytics to Grafana Labs." " These statistics are sent to https://stats.grafana.org/. For more information on what's sent, look at" - "https://github.com/..." # TODO: add url to usage stats code + " https://github.com/grafana/oncall/blob/dev/engine/apps/oss_installation/usage_stats.py#L29" ), "GRAFANA_CLOUD_ONCALL_TOKEN": "Secret token for Grafana Cloud OnCall instance.", "GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED": "Enable hearbeat integration with Grafana Cloud OnCall.", diff --git a/engine/apps/oss_installation/cloud_heartbeat.py b/engine/apps/oss_installation/cloud_heartbeat.py new file mode 100644 index 00000000..e94873ec --- /dev/null +++ b/engine/apps/oss_installation/cloud_heartbeat.py @@ -0,0 +1,110 @@ +import logging +import random +from urllib.parse import urljoin + +import requests +from django.apps import apps +from django.conf import settings +from rest_framework import status + +from apps.base.utils import live_settings + +logger = logging.getLogger(__name__) + + +def setup_heartbeat_integration(name=None): + """Setup Grafana Cloud OnCall heartbeat integration.""" + CloudHeartbeat = apps.get_model("oss_installation", "CloudHeartbeat") + + cloud_heartbeat = None + api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN + if not live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED or not api_token: + return cloud_heartbeat + # don't specify a team in the data, so heartbeat integration will be created in the General. + name = name or f"OnCall Cloud Heartbeat {settings.BASE_URL}" + data = {"type": "formatted_webhook", "name": name} + url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "/api/v1/integrations/") + try: + headers = {"Authorization": api_token} + r = requests.post(url=url, data=data, headers=headers, timeout=5) + if r.status_code == status.HTTP_201_CREATED: + response_data = r.json() + cloud_heartbeat, _ = CloudHeartbeat.objects.update_or_create( + defaults={"integration_id": response_data["id"], "integration_url": response_data["heartbeat"]["link"]} + ) + if r.status_code == status.HTTP_400_BAD_REQUEST: + response_data = r.json() + error = response_data["detail"] + if error == "Integration with this name already exists": + response = requests.get(url=f"{url}?name={name}", headers=headers) + integrations = response.json().get("results", []) + if len(integrations) == 1: + integration = integrations[0] + cloud_heartbeat, updated = CloudHeartbeat.objects.update_or_create( + defaults={ + "integration_id": integration["id"], + "integration_url": integration["heartbeat"]["link"], + } + ) + else: + setup_heartbeat_integration(f"{name}{ random.randint(1, 1024)}") + except requests.Timeout: + logger.warning("Unable to create cloud heartbeat integration. Request timeout.") + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to create cloud heartbeat integration. Request exception {str(e)}.") + return cloud_heartbeat + + +def send_cloud_heartbeat(): + CloudHeartbeat = apps.get_model("oss_installation", "CloudHeartbeat") + CloudConnector = apps.get_model("oss_installation", "CloudConnector") + """Send heartbeat to Grafana Cloud OnCall integration.""" + if not live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED or not live_settings.GRAFANA_CLOUD_ONCALL_TOKEN: + logger.info( + "Unable to send cloud heartbeat. Check values for GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED and GRAFANA_CLOUD_ONCALL_TOKEN." + ) + return + connector = CloudConnector.objects.first() + if connector is None: + logger.info("Unable to send cloud heartbeat. Cloud is not connected") + return + logger.info("Start send cloud heartbeat") + try: + cloud_heartbeat = CloudHeartbeat.objects.get() + except CloudHeartbeat.DoesNotExist: + cloud_heartbeat = setup_heartbeat_integration() + + if cloud_heartbeat is None: + logger.warning("Unable to setup cloud heartbeat integration.") + return + cloud_heartbeat.success = False + try: + response = requests.get(cloud_heartbeat.integration_url, timeout=5) + logger.info(f"Send cloud heartbeat with response {response.status_code}") + except requests.Timeout: + logger.warning("Unable to send cloud heartbeat. Request timeout.") + except requests.exceptions.RequestException as e: + logger.warning(f"Unable to send cloud heartbeat. Request exception {str(e)}.") + else: + if response.status_code == status.HTTP_200_OK: + cloud_heartbeat.success = True + logger.info("Successfully send cloud heartbeat") + elif response.status_code == status.HTTP_403_FORBIDDEN: + # check for 403 because AlertChannelDefiningMixin returns 403 if no integration was found. + logger.info("Failed to send cloud heartbeat. Integration was not created yet") + # force re-creation on next run + cloud_heartbeat.delete() + else: + logger.info(f"Failed to send cloud heartbeat. response {response.status_code}") + # save result of cloud heartbeat if it wasn't deleted + if cloud_heartbeat.pk is not None: + cloud_heartbeat.save() + logger.info("Finish send cloud heartbeat") + + +def get_heartbeat_link(connector, heartbeat): + if connector is None: + return None + if heartbeat is None: + return None + return urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=integrations1&id={heartbeat.integration_id}") diff --git a/engine/apps/oss_installation/serializers/cloud_user.py b/engine/apps/oss_installation/serializers/cloud_user.py index 228a33c9..53ccd808 100644 --- a/engine/apps/oss_installation/serializers/cloud_user.py +++ b/engine/apps/oss_installation/serializers/cloud_user.py @@ -1,9 +1,7 @@ -from urllib.parse import urljoin - from rest_framework import serializers -import apps.oss_installation.constants as cloud_constants from apps.oss_installation.models import CloudConnector, CloudUserIdentity +from apps.oss_installation.utils import cloud_user_identity_status from apps.user_management.models import User @@ -15,23 +13,8 @@ class CloudUserSerializer(serializers.ModelSerializer): fields = ["cloud_data"] def get_cloud_data(self, obj): - link = None - status = cloud_constants.CLOUD_NOT_SYNCED connector = CloudConnector.objects.filter().first() - if connector is not None: - cloud_user_identity = CloudUserIdentity.objects.filter(email=obj.email).first() - if cloud_user_identity is None: - status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND - link = connector.cloud_url - elif not cloud_user_identity.phone_number_verified: - status = cloud_constants.CLOUD_SYNCED_PHONE_NOT_VERIFIED - link = urljoin( - connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_user_identity.cloud_id}" - ) - else: - status = cloud_constants.CLOUD_SYNCED_PHONE_VERIFIED - link = urljoin( - connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_user_identity.cloud_id}" - ) + cloud_user_identity = CloudUserIdentity.objects.filter(email=obj.email).first() + status, link = cloud_user_identity_status(connector, cloud_user_identity) cloud_data = {"status": status, "link": link} return cloud_data diff --git a/engine/apps/oss_installation/tasks.py b/engine/apps/oss_installation/tasks.py index 2bb54991..56e3678a 100644 --- a/engine/apps/oss_installation/tasks.py +++ b/engine/apps/oss_installation/tasks.py @@ -1,13 +1,9 @@ -from urllib.parse import urljoin - -import requests from celery.utils.log import get_task_logger -from django.conf import settings +from django.apps import apps from django.utils import timezone -from rest_framework import status from apps.base.utils import live_settings -from apps.oss_installation.models import CloudConnector, CloudHeartbeat, OssInstallation +from apps.oss_installation.cloud_heartbeat import send_cloud_heartbeat from apps.oss_installation.usage_stats import UsageStatsService from common.custom_celery_tasks import shared_dedicated_queue_retry_task @@ -17,6 +13,8 @@ logger = get_task_logger(__name__) @shared_dedicated_queue_retry_task() def send_usage_stats_report(): logger.info("Start send_usage_stats_report") + OssInstallation = apps.get_model("oss_installation", "OssInstallation") + installation = OssInstallation.objects.get_or_create()[0] enabled = live_settings.SEND_ANONYMOUS_USAGE_STATS if enabled: @@ -30,80 +28,24 @@ def send_usage_stats_report(): logger.info("Finish send_usage_stats_report") -def _setup_heartbeat_integration(): - """Setup Grafana Cloud OnCall heartbeat integration.""" - cloud_heartbeat = None - api_token = live_settings.GRAFANA_CLOUD_ONCALL_TOKEN - # don't specify a team in the data, so heartbeat integration will be created in the General. - data = {"type": "formatted_webhook", "name": f"OnCall {settings.BASE_URL}"} - url = urljoin(settings.GRAFANA_CLOUD_ONCALL_API_URL, "/api/v1/integrations/") - try: - headers = {"Authorization": api_token} - r = requests.post(url=url, data=data, headers=headers, timeout=5) - if r.status_code == status.HTTP_201_CREATED: - response_data = r.json() - cloud_heartbeat, _ = CloudHeartbeat.objects.update_or_create( - defaults={"integration_id": response_data["id"], "integration_url": response_data["heartbeat"]["link"]} - ) - except requests.Timeout: - logger.warning("Unable to create cloud heartbeat integration. Request timeout.") - except requests.exceptions.RequestException as e: - logger.warning(f"Unable to create cloud heartbeat integration. Request exception {str(e)}.") - return cloud_heartbeat - - @shared_dedicated_queue_retry_task() -def send_cloud_heartbeat(): - """Send heartbeat to Grafana Cloud OnCall integration.""" - if not live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED or not live_settings.GRAFANA_CLOUD_ONCALL_TOKEN: - logger.info( - "Unable to send cloud heartbeat. Check values for GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED and GRAFANA_CLOUD_ONCALL_TOKEN." - ) - return - - logger.info("Start send cloud heartbeat") - try: - cloud_heartbeat = CloudHeartbeat.objects.get() - except CloudHeartbeat.DoesNotExist: - cloud_heartbeat = _setup_heartbeat_integration() - - if cloud_heartbeat is None: - logger.warning("Unable to setup cloud heartbeat integration.") - return - cloud_heartbeat.success = False - try: - response = requests.get(cloud_heartbeat.integration_url, timeout=5) - logger.info(f"Send cloud heartbeat with response {response.status_code}") - except requests.Timeout: - logger.warning("Unable to send cloud heartbeat. Request timeout.") - except requests.exceptions.RequestException as e: - logger.warning(f"Unable to send cloud heartbeat. Request exception {str(e)}.") - else: - if response.status_code == status.HTTP_200_OK: - cloud_heartbeat.success = True - logger.info("Successfully send cloud heartbeat") - elif response.status_code == status.HTTP_403_FORBIDDEN: - # check for 403 because AlertChannelDefiningMixin returns 403 if no integration was found. - logger.info("Failed to send cloud heartbeat. Integration was not created yet") - # force re-creation on next run - cloud_heartbeat.delete() - else: - logger.info(f"Failed to send cloud heartbeat. response {response.status_code}") - # save result of cloud heartbeat if it wasn't deleted - if cloud_heartbeat.pk is not None: - cloud_heartbeat.save() - logger.info("Finish send cloud heartbeat") +def send_cloud_heartbeat_task(): + send_cloud_heartbeat() @shared_dedicated_queue_retry_task() def sync_users_with_cloud(): + CloudConnector = apps.get_model("oss_installation", "CloudConnector") logger.info("Start sync_users_with_cloud") - connector = CloudConnector.objects.first() - if connector is not None: - status, error = connector.sync_users_with_cloud() - log_message = "Users synced. Status {status}." - if error: - log_message += f" Error {error}" - logger.info(log_message) + if live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED: + connector = CloudConnector.objects.first() + if connector is not None: + status, error = connector.sync_users_with_cloud() + log_message = "Users synced. Status {status}." + if error: + log_message += f" Error {error}" + logger.info(log_message) + else: + logger.info("Grafana Cloud is not connected") else: - logger.info("Grafana Cloud is not connected") + logger.info("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED is not enabled") diff --git a/engine/apps/oss_installation/urls.py b/engine/apps/oss_installation/urls.py index 9ff5efc2..ddf04020 100644 --- a/engine/apps/oss_installation/urls.py +++ b/engine/apps/oss_installation/urls.py @@ -2,7 +2,7 @@ from django.urls import include, path from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path -from .views import CloudConnectionView, CloudUsersView, CloudUserView +from .views import CloudConnectionView, CloudHeartbeatView, CloudUsersView, CloudUserView router = OptionalSlashRouter() router.register("cloud_users", CloudUserView, basename="cloud-users") @@ -11,4 +11,5 @@ urlpatterns = [ path("", include(router.urls)), optional_slash_path("cloud_users", CloudUsersView.as_view(), name="cloud-users-list"), optional_slash_path("cloud_connection", CloudConnectionView.as_view(), name="cloud-connection-status"), + optional_slash_path("cloud_heartbeat", CloudHeartbeatView.as_view(), name="cloud-heartbeat"), ] diff --git a/engine/apps/oss_installation/usage_stats.py b/engine/apps/oss_installation/usage_stats.py index db90cce8..b3a1bd43 100644 --- a/engine/apps/oss_installation/usage_stats.py +++ b/engine/apps/oss_installation/usage_stats.py @@ -3,11 +3,11 @@ import platform from dataclasses import asdict, dataclass import requests +from django.apps import apps from django.conf import settings from django.db.models import Sum from apps.alerts.models import AlertGroupCounter -from apps.oss_installation.models import OssInstallation from apps.oss_installation.utils import active_oss_users_count USAGE_STATS_URL = "https://stats.grafana.org/oncall-usage-report" @@ -27,9 +27,12 @@ class UsageStatsReport: class UsageStatsService: def get_usage_stats_report(self): + OssInstallation = apps.get_model("oss_installation", "OssInstallation") metrics = {} metrics["active_users_count"] = active_oss_users_count() - total_alert_groups = AlertGroupCounter.objects.aggregate(Sum("value")).get("value__sum", 0) + total_alert_groups = AlertGroupCounter.objects.aggregate(Sum("value")).get("value__sum", None) + if total_alert_groups is None: + total_alert_groups = 0 metrics["alert_groups_count"] = total_alert_groups usage_stats_id = OssInstallation.objects.get_or_create()[0].installation_id diff --git a/engine/apps/oss_installation/utils.py b/engine/apps/oss_installation/utils.py index c8a0e65b..4aad084a 100644 --- a/engine/apps/oss_installation/utils.py +++ b/engine/apps/oss_installation/utils.py @@ -1,8 +1,10 @@ import logging +from urllib.parse import urljoin from django.apps import apps from django.utils import timezone +from apps.oss_installation import constants as oss_constants from apps.schedules.ical_utils import list_users_to_notify_from_ical_for_period logger = logging.getLogger(__name__) @@ -65,3 +67,20 @@ def active_oss_users_count(): unique_active_users.add(user.pk) return len(unique_active_users) + + +def cloud_user_identity_status(connector, identity): + link = None + if connector is None: + status = oss_constants.CLOUD_NOT_SYNCED + elif identity is None: + status = oss_constants.CLOUD_SYNCED_USER_NOT_FOUND + link = connector.cloud_url + else: + if identity.phone_number_verified: + status = oss_constants.CLOUD_SYNCED_PHONE_VERIFIED + else: + status = oss_constants.CLOUD_SYNCED_PHONE_NOT_VERIFIED + + link = urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={identity.cloud_id}") + return status, link diff --git a/engine/apps/oss_installation/views/__init__.py b/engine/apps/oss_installation/views/__init__.py index 2b206cac..b3c50ba3 100644 --- a/engine/apps/oss_installation/views/__init__.py +++ b/engine/apps/oss_installation/views/__init__.py @@ -1,2 +1,3 @@ from .cloud_connection import CloudConnectionView # noqa: F401 +from .cloud_heartbeat import CloudHeartbeatView # noqa: F401 from .cloud_users import CloudUsersView, CloudUserView # noqa: F401 diff --git a/engine/apps/oss_installation/views/cloud_connection.py b/engine/apps/oss_installation/views/cloud_connection.py index 6acbef57..21b6624c 100644 --- a/engine/apps/oss_installation/views/cloud_connection.py +++ b/engine/apps/oss_installation/views/cloud_connection.py @@ -1,5 +1,3 @@ -from urllib.parse import urljoin - from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -7,7 +5,9 @@ from rest_framework.views import APIView from apps.api.permissions import IsAdmin from apps.auth_token.auth import PluginAuthentication +from apps.base.models import LiveSetting from apps.base.utils import live_settings +from apps.oss_installation.cloud_heartbeat import get_heartbeat_link from apps.oss_installation.models import CloudConnector, CloudHeartbeat @@ -22,19 +22,16 @@ class CloudConnectionView(APIView): "cloud_connection_status": connector is not None, "cloud_notifications_enabled": live_settings.GRAFANA_CLOUD_NOTIFICATIONS_ENABLED, "cloud_heartbeat_enabled": live_settings.GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED, - "cloud_heartbeat_link": self._get_heartbeat_link(connector, heartbeat), + "cloud_heartbeat_link": get_heartbeat_link(connector, heartbeat), "cloud_heartbeat_status": heartbeat is not None and heartbeat.success, } return Response(response) - def _get_heartbeat_link(self, connector, heartbeat): - if connector is None: - return None - if heartbeat is None: - return None - return urljoin(connector.cloud_url, f"a/grafana-oncall-app/?page=integrations1&id={heartbeat.integration_id}") - def delete(self, request): + s = LiveSetting.objects.filter(name="GRAFANA_CLOUD_ONCALL_TOKEN").first() + if s is not None: + s.value = None + s.save() connector = CloudConnector.objects.first() if connector is None: return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/engine/apps/oss_installation/views/cloud_heartbeat.py b/engine/apps/oss_installation/views/cloud_heartbeat.py new file mode 100644 index 00000000..932087c3 --- /dev/null +++ b/engine/apps/oss_installation/views/cloud_heartbeat.py @@ -0,0 +1,27 @@ +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.api.permissions import IsAdmin +from apps.auth_token.auth import PluginAuthentication +from apps.oss_installation.cloud_heartbeat import get_heartbeat_link, setup_heartbeat_integration +from apps.oss_installation.models import CloudConnector, CloudHeartbeat + + +class CloudHeartbeatView(APIView): + authentication_classes = (PluginAuthentication,) + permission_classes = (IsAuthenticated, IsAdmin) + + def post(self, request): + connector = CloudConnector.objects.first() + if connector is not None: + try: + CloudHeartbeat.objects.get() + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Cloud heartbeat already exists"}) + except CloudHeartbeat.DoesNotExist: + heartbeat = setup_heartbeat_integration() + link = get_heartbeat_link(connector, heartbeat) + return Response(status=status.HTTP_200_OK, data={"link": link}) + else: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "Grafana Cloud is not connected"}) diff --git a/engine/apps/oss_installation/views/cloud_users.py b/engine/apps/oss_installation/views/cloud_users.py index 2f740b64..3eb7685b 100644 --- a/engine/apps/oss_installation/views/cloud_users.py +++ b/engine/apps/oss_installation/views/cloud_users.py @@ -1,4 +1,4 @@ -from urllib.parse import urljoin +from collections import OrderedDict from rest_framework import mixins, status, viewsets from rest_framework.decorators import action @@ -6,11 +6,11 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -import apps.oss_installation.constants as cloud_constants from apps.api.permissions import ActionPermission, AnyRole, IsAdmin, IsOwnerOrAdmin from apps.auth_token.auth import PluginAuthentication from apps.oss_installation.models import CloudConnector, CloudUserIdentity from apps.oss_installation.serializers import CloudUserSerializer +from apps.oss_installation.utils import cloud_user_identity_status from apps.user_management.models import User from common.api_helpers.mixins import PublicPrimaryKeyMixin from common.api_helpers.paginators import HundredPageSizePaginator @@ -28,10 +28,10 @@ class CloudUsersView(HundredPageSizePaginator, APIView): if request.user.current_team is not None: queryset = queryset.filter(teams=request.user.current_team).distinct() + emails = list(queryset.values_list("email", flat=True)) results = self.paginate_queryset(queryset, request, view=self) - emails = list(queryset.values_list("email", flat=True)) cloud_identities = list(CloudUserIdentity.objects.filter(email__in=emails)) cloud_identities = {cloud_identity.email: cloud_identity for cloud_identity in cloud_identities} @@ -40,20 +40,8 @@ class CloudUsersView(HundredPageSizePaginator, APIView): connector = CloudConnector.objects.first() for user in results: - link = None - status = cloud_constants.CLOUD_NOT_SYNCED - if connector is not None: - status = cloud_constants.CLOUD_SYNCED_USER_NOT_FOUND - cloud_identity = cloud_identities.get(user.email, None) - if cloud_identity: - status = cloud_constants.CLOUD_SYNCED_PHONE_NOT_VERIFIED - is_phone_verified = cloud_identity.phone_number_verified - if is_phone_verified: - status = cloud_constants.CLOUD_SYNCED_PHONE_VERIFIED - link = urljoin( - connector.cloud_url, f"a/grafana-oncall-app/?page=users&p=1&id={cloud_identity.cloud_id}" - ) - + cloud_identity = cloud_identities.get(user.email, None) + status, link = cloud_user_identity_status(connector, cloud_identity) response.append( { "id": user.public_primary_key, @@ -63,7 +51,20 @@ class CloudUsersView(HundredPageSizePaginator, APIView): } ) - return self.get_paginated_response(response) + return self.get_paginated_response_with_matched_users_count(response, len(cloud_identities)) + + def get_paginated_response_with_matched_users_count(self, data, matched_users_count): + return Response( + OrderedDict( + [ + ("count", self.page.paginator.count), + ("matched_users_count", matched_users_count), + ("next", self.get_next_link()), + ("previous", self.get_previous_link()), + ("results", data), + ] + ) + ) def post(self, request): connector = CloudConnector.objects.first() diff --git a/engine/apps/public_api/views/integrations.py b/engine/apps/public_api/views/integrations.py index 0e5fac35..447c5b2b 100644 --- a/engine/apps/public_api/views/integrations.py +++ b/engine/apps/public_api/views/integrations.py @@ -41,6 +41,10 @@ class IntegrationView( queryset = AlertReceiveChannel.objects.filter(organization=self.request.auth.organization).order_by( "created_at" ) + name = self.request.query_params.get("name", None) + if name is not None: + queryset = queryset.filter(verbal_name=name) + queryset = self.filter_queryset(queryset) queryset = self.serializer_class.setup_eager_loading(queryset) queryset = queryset.annotate(alert_groups_count_annotated=Count("alert_groups", distinct=True)) return queryset diff --git a/engine/settings/all_in_one.py b/engine/settings/all_in_one.py index 221edd52..5c90e3e3 100644 --- a/engine/settings/all_in_one.py +++ b/engine/settings/all_in_one.py @@ -1,5 +1,4 @@ import sys -from random import randrange from .prod_without_db import * # noqa @@ -37,27 +36,3 @@ CELERY_BROKER_URL = "redis://localhost:6379/0" if TESTING: TELEGRAM_TOKEN = "0000000000:XXXXXXXXXXXXXXXXXXXXXXXXXXXX-XXXXXX" TWILIO_AUTH_TOKEN = "twilio_auth_token" - -# TODO: OSS: Add these setting to oss settings file. Add Version there too. -OSS_INSTALLATION_FEATURES_ENABLED = True -SEND_ANONYMOUS_USAGE_STATS = True - -INSTALLED_APPS += ["apps.oss_installation"] # noqa - -CELERY_BEAT_SCHEDULE["send_usage_stats"] = { # noqa - "task": "apps.oss_installation.tasks.send_usage_stats_report", - "schedule": crontab(hour=0, minute=randrange(0, 59)), # Send stats report at a random minute past midnight # noqa - "args": (), -} # noqa - -CELERY_BEAT_SCHEDULE["send_cloud_heartbeat"] = { # noqa - "task": "apps.oss_installation.tasks.send_cloud_heartbeat", - "schedule": crontab(minute="*/3"), # noqa - "args": (), -} # noqa - -CELERY_BEAT_SCHEDULE["sync_users_with_cloud"] = { # noqa - "task": "apps.oss_installation.tasks.sync_users_with_cloud", - "schedule": crontab(hour="*/12"), # noqa - "args": (), -} # noqa diff --git a/engine/settings/base.py b/engine/settings/base.py index 9b6cca81..b24c2f17 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -1,4 +1,5 @@ import os +from random import randrange from urllib.parse import urljoin from celery.schedules import crontab @@ -7,8 +8,8 @@ from common.utils import getenv_boolean VERSION = "dev-oss" # Indicates if instance is OSS installation. -# It is needed to plug-in oss urls. -OSS_INSTALLATION = getenv_boolean("OSS", False) +# It is needed to plug-in oss application and urls. +OSS_INSTALLATION = getenv_boolean("GRAFANA_ONCALL_OSS_INSTALLATION", True) SEND_ANONYMOUS_USAGE_STATS = getenv_boolean("SEND_ANONYMOUS_USAGE_STATS", default=True) # License is OpenSource or Cloud @@ -441,3 +442,26 @@ INSTALLED_ONCALL_INTEGRATIONS = [ "config_integrations.manual", "config_integrations.slack_channel", ] + +if OSS_INSTALLATION: + INSTALLED_APPS += ["apps.oss_installation"] # noqa + + CELERY_BEAT_SCHEDULE["send_usage_stats"] = { # noqa + "task": "apps.oss_installation.tasks.send_usage_stats_report", + "schedule": crontab( + hour=0, minute=randrange(0, 59) + ), # Send stats report at a random minute past midnight # noqa + "args": (), + } # noqa + + CELERY_BEAT_SCHEDULE["send_cloud_heartbeat"] = { # noqa + "task": "apps.oss_installation.tasks.send_cloud_heartbeat_task", + "schedule": crontab(minute="*/3"), # noqa + "args": (), + } # noqa + + CELERY_BEAT_SCHEDULE["sync_users_with_cloud"] = { # noqa + "task": "apps.oss_installation.tasks.sync_users_with_cloud", + "schedule": crontab(hour="*/12"), # noqa + "args": (), + } # noqa diff --git a/engine/settings/ci-test.py b/engine/settings/ci-test.py index 5389cbd5..f3c012a0 100644 --- a/engine/settings/ci-test.py +++ b/engine/settings/ci-test.py @@ -27,5 +27,3 @@ TWILIO_AUTH_TOKEN = "dummy_twilio_auth_token" FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED = True EXTRA_MESSAGING_BACKENDS = ["apps.base.tests.messaging_backend.TestOnlyBackend"] -OSS_INSTALLATION = True -INSTALLED_APPS += ["apps.oss_installation"] # noqa diff --git a/engine/settings/hobby.py b/engine/settings/hobby.py index 4b2a4e8f..3bd73c13 100644 --- a/engine/settings/hobby.py +++ b/engine/settings/hobby.py @@ -36,22 +36,3 @@ MIRAGE_CIPHER_IV = "1234567890abcdef" # use default APPEND_SLASH = False SECURE_SSL_REDIRECT = False - -# TODO: OSS: Add these setting to oss settings file. Add Version there too. -OSS_INSTALLATION_FEATURES_ENABLED = True - -INSTALLED_APPS += ["apps.oss_installation"] # noqa - -CELERY_BEAT_SCHEDULE["send_usage_stats"] = { # noqa - "task": "apps.oss_installation.tasks.send_usage_stats_report", - "schedule": crontab(hour=0, minute=randrange(0, 59)), # Send stats report at a random minute past midnight # noqa - "args": (), -} # noqa - -CELERY_BEAT_SCHEDULE["send_cloud_heartbeat"] = { # noqa - "task": "apps.oss_installation.tasks.send_cloud_heartbeat", - "schedule": crontab(minute="*/3"), # noqa - "args": (), -} # noqa - -SEND_ANONYMOUS_USAGE_STATS = True diff --git a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx index 040f4b43..29254a04 100644 --- a/grafana-plugin/src/components/Policy/NotificationPolicy.tsx +++ b/grafana-plugin/src/components/Policy/NotificationPolicy.tsx @@ -35,7 +35,7 @@ export interface NotificationPolicyProps { waitDelays?: WaitDelay[]; notifyByOptions?: NotifyBy[]; telegramVerified: boolean; - phoneVerified: boolean; + phoneStatus: number; color: string; number: number; userAction: UserAction; @@ -115,13 +115,21 @@ export class NotificationPolicy extends React.ComponentPhone number is verified - ) : ( - Phone number is not verified - ); + switch (phoneStatus) { + case 0: + return Cloud is not synced; + case 1: + return User is not matched with cloud; + case 2: + return Phone number is not verified; + case 3: + return Phone number is verified; + + default: + return null; + } } _renderTelegramNote() { diff --git a/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx b/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx index 4812b0ae..a9d2205e 100644 --- a/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx +++ b/grafana-plugin/src/containers/PersonalNotificationSettings/PersonalNotificationSettings.tsx @@ -12,6 +12,7 @@ import Timeline from 'components/Timeline/Timeline'; import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { NotificationPolicyType } from 'models/notification_policy'; import { User as UserType } from 'models/user/user.types'; +import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import { UserAction } from 'state/userAction'; @@ -105,6 +106,12 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin const user = userStore.items[userPk]; const userAction = isCurrent ? UserAction.UpdateOwnSettings : UserAction.UpdateNotificationPolicies; + const getPhoneStatus = () => { + if (store.hasFeature(AppFeature.CloudNotifications)) { + return user.cloud_connection_status; + } + return Number(user.verified_phone_number) + 2; + }; return (
@@ -124,7 +131,7 @@ const PersonalNotificationSettings = observer((props: PersonalNotificationSettin index={index} number={index + 1} telegramVerified={Boolean(user.telegram_configuration)} - phoneVerified={Boolean(user && user.verified_phone_number)} + phoneStatus={getPhoneStatus()} slackTeamIdentity={store.teamStore.currentTeam?.slack_team_identity} slackUserIdentity={user.slack_user_identity} data={notificationPolicy} diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx b/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx index b00cf868..05ec4509 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/PhoneConnector.tsx @@ -1,11 +1,12 @@ import React, { useCallback } from 'react'; -import { Button, Label } from '@grafana/ui'; +import { Button, Label, VerticalGroup } from '@grafana/ui'; import cn from 'classnames/bind'; import Text from 'components/Text/Text'; import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types'; import { User } from 'models/user/user.types'; +import { AppFeature } from 'state/features'; import { useStore } from 'state/useStore'; import styles from './index.module.css'; @@ -29,31 +30,85 @@ const PhoneConnector = (props: PhoneConnectorProps) => { onTabChange(UserSettingsTab.PhoneVerification); }, [storeUser?.unverified_phone_number]); + const cloudVersionPhone = (user: User) => { + switch (user.cloud_connection_status) { + case 0: + return Cloud is not synced; + + case 1: + return ( + + User is not matched with cloud + + + ); + + case 2: + return ( + + Phone number is not verified in Grafana Cloud + + + ); + case 3: + return ( + + Phone number verified + + + ); + default: + return ( + + User is not matched with cloud + + + ); + } + }; + return (
- - {storeUser.verified_phone_number || '—'} - {storeUser.verified_phone_number ? ( -
- Phone number is verified - -
- ) : storeUser.unverified_phone_number ? ( -
- Phone number is not verified - -
+ {store.hasFeature(AppFeature.CloudNotifications) ? ( + <> + + {cloudVersionPhone(storeUser)} + ) : ( -
- Phone number is not added - -
+ <> + + {storeUser.verified_phone_number || '—'} + {storeUser.verified_phone_number ? ( +
+ Phone number is verified + +
+ ) : storeUser.unverified_phone_number ? ( +
+ Phone number is not verified + +
+ ) : ( +
+ Phone number is not added + +
+ )} + )}
); diff --git a/grafana-plugin/src/containers/UserSettings/parts/connectors/index.module.css b/grafana-plugin/src/containers/UserSettings/parts/connectors/index.module.css index 0e32b304..04f4550e 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/connectors/index.module.css +++ b/grafana-plugin/src/containers/UserSettings/parts/connectors/index.module.css @@ -30,3 +30,7 @@ .warning-icon { color: var(--warning-text-color); } + +.error-message { + color: var(--error-text-color); +} diff --git a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx index 842869f7..724b4712 100644 --- a/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx +++ b/grafana-plugin/src/containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings.tsx @@ -48,12 +48,15 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => { }, []); const handleLinkClick = (link: string) => { - getLocationSrv().update({ partial: false, path: link }); + window.location.replace(link); }; const syncUser = async () => { setSyncing(true); await store.cloudStore.syncCloudUser(userPk); + const cloudUser = await store.cloudStore.getCloudUser(userPk); + setUserStatus(cloudUser?.cloud_data?.status); + setUserLink(cloudUser?.cloud_data?.link); setSyncing(false); }; diff --git a/grafana-plugin/src/index.css b/grafana-plugin/src/index.css index 93b9dfe1..eeeff2de 100644 --- a/grafana-plugin/src/index.css +++ b/grafana-plugin/src/index.css @@ -30,13 +30,13 @@ background: var(--highlighted-row-bg); } -@media (max-width: 1440px) { +@media (max-width: 1540px) { .page-header__tabs > ul > li > a > div { display: none; } } -@media (max-width: 1200px) { +@media (max-width: 1300px) { .sidemenu { position: fixed !important; height: 100%; diff --git a/grafana-plugin/src/models/base_store.ts b/grafana-plugin/src/models/base_store.ts index 9af0c5d4..ab46fe3c 100644 --- a/grafana-plugin/src/models/base_store.ts +++ b/grafana-plugin/src/models/base_store.ts @@ -52,10 +52,11 @@ export default class BaseStore { } @action - async update(id: any, data: any) { + async update(id: any, data: any, params: any = null) { const result = await makeRequest(`${this.path}${id}/`, { method: 'PUT', data, + params: params, }).catch(this.onApiError); // Update env_status field for current team diff --git a/grafana-plugin/src/models/cloud/cloud.ts b/grafana-plugin/src/models/cloud/cloud.ts index d917075b..fa19125a 100644 --- a/grafana-plugin/src/models/cloud/cloud.ts +++ b/grafana-plugin/src/models/cloud/cloud.ts @@ -13,7 +13,7 @@ import { Cloud } from './cloud.types'; export class CloudStore extends BaseStore { @observable.shallow - searchResult: { count?: number; results?: Array } = {}; + searchResult: { matched_users_count?: number; results?: Array } = {}; @observable.shallow items: { [id: string]: Cloud } = {}; @@ -26,7 +26,7 @@ export class CloudStore extends BaseStore { @action async updateItems(page = 1) { - const { count, results } = await makeRequest(this.path, { + const { matched_users_count, results } = await makeRequest(this.path, { params: { page }, }); @@ -42,14 +42,14 @@ export class CloudStore extends BaseStore { }; this.searchResult = { - count, + matched_users_count, results: results.map((item: Cloud) => item.id), }; } getSearchResult() { return { - count: this.searchResult.count, + matched_users_count: this.searchResult.matched_users_count, results: this.searchResult.results && this.searchResult.results.map((id: Cloud['id']) => this.items?.[id]), }; } @@ -62,6 +62,12 @@ export class CloudStore extends BaseStore { return await makeRequest(`${this.path}${id}/sync/`, { method: 'POST' }); } + async getCloudHeartbeat() { + return await makeRequest(`/cloud_heartbeat/`, { method: 'POST' }).catch((error) => { + console.log(error); + }); + } + async getCloudUser(id: string) { return await makeRequest(`${this.path}${id}`, { method: 'GET' }); } diff --git a/grafana-plugin/src/models/user/user.types.ts b/grafana-plugin/src/models/user/user.types.ts index 4dd3f00a..4f1ba2ed 100644 --- a/grafana-plugin/src/models/user/user.types.ts +++ b/grafana-plugin/src/models/user/user.types.ts @@ -52,4 +52,5 @@ export interface User { export_url?: string; status?: number; link?: string; + cloud_connection_status?: number; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.module.css b/grafana-plugin/src/pages/cloud/CloudPage.module.css index 14f11ba5..416d2a70 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.module.css +++ b/grafana-plugin/src/pages/cloud/CloudPage.module.css @@ -25,7 +25,8 @@ height: 32px; } -.cloud-page-title { +.cloud-page-title, +.heartbit-button { margin-top: 24px; } diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index 02ae2493..d81ce0c9 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -41,8 +41,9 @@ const CloudPage = observer((props: CloudPageProps) => { const [cloudApiKey, setCloudApiKey] = useState(''); const [apiKeyError, setApiKeyError] = useState(false); const [cloudIsConnected, setCloudIsConnected] = useState(undefined); - const [heartbitLink, setHeartbitLink] = useState(null); - const [heartbitStatus, setHeartbitStatus] = useState(false); + const [cloudNotificationsEnabled, setCloudNotificationsEnabled] = useState(false); + const [heartbeatLink, setheartbeatLink] = useState(null); + const [heartbeatEnabled, setheartbeatEnabled] = useState(false); const [showConfirmationModal, setShowConfirmationModal] = useState(false); const [syncingUsers, setSyncingUsers] = useState(false); @@ -50,13 +51,13 @@ const CloudPage = observer((props: CloudPageProps) => { store.cloudStore.updateItems(page); store.cloudStore.getCloudConnectionStatus().then((cloudStatus) => { setCloudIsConnected(cloudStatus.cloud_connection_status); - setHeartbitStatus(cloudStatus.cloud_heartbeat_enabled); - setHeartbitLink(cloudStatus.cloud_heartbeat_link); - getApiKeyFromGlobalSettings(); + setheartbeatEnabled(cloudStatus.cloud_heartbeat_enabled); + setheartbeatLink(cloudStatus.cloud_heartbeat_link); + setCloudNotificationsEnabled(cloudStatus.cloud_notifications_enabled); }); }, [cloudIsConnected]); - const { count, results } = store.cloudStore.getSearchResult(); + const { matched_users_count, results } = store.cloudStore.getSearchResult(); const handleChangePage = (page: number) => { setPage(page); @@ -77,18 +78,12 @@ const CloudPage = observer((props: CloudPageProps) => { store.cloudStore.disconnectToCloud(); }; - const getApiKeyFromGlobalSettings = async () => { - const globalSettingItem = await store.globalSettingStore.getGlobalSettingItemByName('GRAFANA_CLOUD_ONCALL_TOKEN'); - if (cloudIsConnected === false) { - setCloudApiKey(globalSettingItem?.value); - } - }; const connectToCloud = async () => { setShowConfirmationModal(false); const globalSettingItem = await store.globalSettingStore.getGlobalSettingItemByName('GRAFANA_CLOUD_ONCALL_TOKEN'); store.globalSettingStore - .update(globalSettingItem?.id, { name: 'GRAFANA_CLOUD_ONCALL_TOKEN', value: cloudApiKey }) - .then((response) => { + .update(globalSettingItem?.id, { name: 'GRAFANA_CLOUD_ONCALL_TOKEN', value: cloudApiKey }, { sync_users: false }) + .then(async (response) => { if (response.error) { setCloudIsConnected(false); setApiKeyError(true); @@ -96,6 +91,8 @@ const CloudPage = observer((props: CloudPageProps) => { } else { setCloudIsConnected(true); syncUsers(); + const heartbeatData: { link: string } = await store.cloudStore.getCloudHeartbeat(); + setheartbeatLink(heartbeatData?.link); } }); }; @@ -108,7 +105,7 @@ const CloudPage = observer((props: CloudPageProps) => { }; const handleLinkClick = (link: string) => { - getLocationSrv().update({ partial: false, path: link }); + window.location.replace(link); }; const renderButtons = (user: Cloud) => { @@ -246,67 +243,87 @@ const CloudPage = observer((props: CloudPageProps) => { Once connected, current OnCall instance will send heartbeats every 3 minutes to the cloud Instance. If no heartbeat will be received in 10 minutes, cloud instance will issue an alert. - {heartbitStatus && heartbitLink && ( - - )} +
+ {heartbeatEnabled ? ( + heartbeatLink ? ( + + ) : ( + Heartbeat will be created in a moment automatically + ) + ) : ( + Heartbeat is not enabled. You can go to the Env Variables tab and enable it + )} +
- - - SMS and phone call notifications - + {cloudNotificationsEnabled ? ( + + + SMS and phone call notifications + -
+
+ + { + 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings! Only users with Admin or Editor role will be synced.' + } + + + ( +
+ + + {matched_users_count ? matched_users_count : 0} user + {matched_users_count === 1 ? '' : 's'} + {` matched between OSS and Cloud OnCall`} + + {syncingUsers ? ( + + ) : ( + + )} + +
+ )} + rowKey="id" + // @ts-ignore + columns={columns} + data={results} + pagination={{ + page, + total: Math.ceil((matched_users_count || 0) / ITEMS_PER_PAGE), + onChange: handleChangePage, + }} + /> +
+ + ) : ( + + + SMS and phone call notifications + - { - 'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notificaitons in personal settings! Only users with Admin or Editor role will be synced.' - } + {'Please enable Grafana cloud notification to be able to see list of cloud users'} - - ( -
- - - {count ? count : 0} - {` users matched between OSS and Cloud OnCall`} - - {syncingUsers ? ( - - ) : ( - - )} - -
- )} - rowKey="id" - // @ts-ignore - columns={columns} - data={results} - pagination={{ - page, - total: Math.ceil((count || 0) / ITEMS_PER_PAGE), - onChange: handleChangePage, - }} - /> -
-
+
+ )}
); @@ -324,7 +341,7 @@ const CloudPage = observer((props: CloudPageProps) => { style={{ width: '100%' }} invalid={apiKeyError} > - + From 198f97142fae3b9fdb5df4599a4d6f5f265d1d3d Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 15:29:23 +0300 Subject: [PATCH 119/132] Fixing utf8 and docker compose --- docker-compose-developer.yml | 2 +- docker-compose.yml | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index 622eddd4..e35c3c70 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -7,7 +7,7 @@ services: platform: linux/x86_64 mem_limit: 500m cpus: 0.5 - command: --default-authentication-plugin=mysql_native_password + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci restart: always ports: - 3306:3306 diff --git a/docker-compose.yml b/docker-compose.yml index 457fd6b5..c4695fd8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,13 +92,15 @@ services: depends_on: mysql: condition: service_healthy + rabbitmq: + condition: service_started mysql: image: mysql:5.7 platform: linux/x86_64 mem_limit: 500m cpus: 0.5 - command: --default-authentication-plugin=mysql_native_password + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci restart: always ports: - 3306:3306 From bfa2966f811083de3e086319b46daddc0c2d9a10 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 16:38:21 +0300 Subject: [PATCH 120/132] 8080 -> 8000 port for consistency --- README.md | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a726318a..0060cde3 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ docker-compose --env-file .env_hobby -f docker-compose.yml run engine python man 5. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app) (or find OnCall plugin in configuration->plugins) and connect OnCall _plugin_ with OnCall _backend_: ``` Invite token: ^^^ from the previous step. -OnCall backend URL: http://engine:8080 +OnCall backend URL: http://engine:8000 Grafana Url: http://grafana:3000 ``` diff --git a/docker-compose.yml b/docker-compose.yml index c4695fd8..73a2cb62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: # image: ... build: engine ports: - - 8080:8080 + - 8000:8000 command: > sh -c "uwsgi --ini uwsgi.ini" environment: From fe7acb386363e7d17739abd86d6b3a4673693ee0 Mon Sep 17 00:00:00 2001 From: Yulia Shanyrova Date: Tue, 14 Jun 2022 15:51:32 +0200 Subject: [PATCH 121/132] makeReq --- grafana-plugin/src/pages/cloud/CloudPage.tsx | 24 +------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/grafana-plugin/src/pages/cloud/CloudPage.tsx b/grafana-plugin/src/pages/cloud/CloudPage.tsx index 0ba8f093..63df35e0 100644 --- a/grafana-plugin/src/pages/cloud/CloudPage.tsx +++ b/grafana-plugin/src/pages/cloud/CloudPage.tsx @@ -69,10 +69,6 @@ const CloudPage = observer((props: CloudPageProps) => { setApiKeyError(false); }, []); - const saveKeyAndConnect = () => { - setShowConfirmationModal(true); - }; - const disconnectCloudOncall = () => { setCloudIsConnected(false); store.cloudStore.disconnectToCloud(); @@ -130,7 +126,6 @@ const CloudPage = observer((props: CloudPageProps) => { return ( @@ -387,23 +382,6 @@ const CloudPage = observer((props: CloudPageProps) => { ) : ( DisconnectedBlock )} - - {showConfirmationModal && ( - setShowConfirmationModal(false)} - > - - - - - - )}
); From 8e91d87fcc5a1ad4c81389d6462bf435552016ca Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 17:12:14 +0300 Subject: [PATCH 122/132] Fixing images --- engine/settings/base.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/engine/settings/base.py b/engine/settings/base.py index 16d605ac..baba3861 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -226,9 +226,7 @@ USE_TZ = True # https://docs.djangoproject.com/en/2.1/howto/static-files/ STATIC_URL = "/static/" -STATICFILES_DIRS = [ - "./static", -] +STATIC_ROOT = "./static/" CELERY_BROKER_URL = "amqp://rabbitmq:rabbitmq@localhost:5672" From 98dc6ebe3f1e98a36cecc39275525f889b20e52e Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 17:20:49 +0300 Subject: [PATCH 123/132] Fixing port --- engine/uwsgi.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/uwsgi.ini b/engine/uwsgi.ini index 17a92d60..7f271adb 100644 --- a/engine/uwsgi.ini +++ b/engine/uwsgi.ini @@ -3,7 +3,7 @@ chdir=/etc/app module=engine.wsgi:application master=True pidfile=/tmp/project-master.pid -http=0.0.0.0:8080 +http=0.0.0.0:8000 processes=5 uid=1000 gid=2000 @@ -18,4 +18,4 @@ post-buffering=1 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} \ No newline at end of file +log-encoder=format ${strftime:%%Y-%%m-%%d %%H:%%M:%%S} ${msgnl} From dbff16c69968f8e0f2c9d0ebce7f877bb2858e40 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 17:21:10 +0300 Subject: [PATCH 124/132] Fixing port --- engine/uwsgi.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/uwsgi.ini b/engine/uwsgi.ini index 7f271adb..6f612248 100644 --- a/engine/uwsgi.ini +++ b/engine/uwsgi.ini @@ -3,7 +3,7 @@ chdir=/etc/app module=engine.wsgi:application master=True pidfile=/tmp/project-master.pid -http=0.0.0.0:8000 +http=0.0.0.0:8080 processes=5 uid=1000 gid=2000 From 54e931ccb48791c6cb31a9863efcf69eb0ecef10 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 17:22:50 +0300 Subject: [PATCH 125/132] Fixing port --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 73a2cb62..354219d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: # image: ... build: engine ports: - - 8000:8000 + - 8000:8080 command: > sh -c "uwsgi --ini uwsgi.ini" environment: From 24a657b61727708311a4b4832b8521ced970eb06 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 17:39:25 +0300 Subject: [PATCH 126/132] Fixing port --- .env.example | 6 ++++-- DEVELOPER.md | 4 ++-- README.md | 2 +- docker-compose.yml | 2 +- docs/sources/open-source.md | 2 +- .../src/containers/PluginConfigPage/PluginConfigPage.tsx | 6 +++--- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index b8794c10..529d3ce9 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ +RUNSERVER_PORT=8080 + SLACK_CLIENT_OAUTH_ID= SLACK_CLIENT_OAUTH_SECRET= SLACK_API_TOKEN= @@ -19,13 +21,13 @@ SENDGRID_FROM_EMAIL= DJANGO_SETTINGS_MODULE=settings.dev SECRET_KEY=jkashdkjashdkjh -BASE_URL=http://localhost:8000 +BASE_URL=http://localhost:8080 FEATURE_TELEGRAM_INTEGRATION_ENABLED=True FEATURE_SLACK_INTEGRATION_ENABLED=True FEATURE_EXTRA_MESSAGING_BACKENDS_ENABLED= -SLACK_INSTALL_RETURN_REDIRECT_HOST=http://localhost:8000 +SLACK_INSTALL_RETURN_REDIRECT_HOST=http://localhost:8080 SOCIAL_AUTH_REDIRECT_IS_HTTPS=False GRAFANA_INCIDENT_STATIC_API_KEY= diff --git a/DEVELOPER.md b/DEVELOPER.md index 8af642f3..08086609 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -73,7 +73,7 @@ python manage.py start_celery celery -A engine beat -l info ``` -4. All set! Check out internal API endpoints at http://localhost:8000/. +4. All set! Check out internal API endpoints at http://localhost:8080/. ### Frontend setup @@ -102,7 +102,7 @@ python manage.py issue_invite_for_the_frontend --override 6. Some configuration fields will appear be available. Fill them out and click Initialize OnCall ``` OnCall API URL: -http://host.docker.internal:8000 +http://host.docker.internal:8080 Invitation Token (Single use token to connect Grafana instance): Response from the invite generator command (check above) diff --git a/README.md b/README.md index 0060cde3..a726318a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ docker-compose --env-file .env_hobby -f docker-compose.yml run engine python man 5. Go to [OnCall Plugin Configuration](http://localhost:3000/plugins/grafana-oncall-app) (or find OnCall plugin in configuration->plugins) and connect OnCall _plugin_ with OnCall _backend_: ``` Invite token: ^^^ from the previous step. -OnCall backend URL: http://engine:8000 +OnCall backend URL: http://engine:8080 Grafana Url: http://grafana:3000 ``` diff --git a/docker-compose.yml b/docker-compose.yml index 354219d0..c4695fd8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: # image: ... build: engine ports: - - 8000:8080 + - 8080:8080 command: > sh -c "uwsgi --ini uwsgi.ini" environment: diff --git a/docs/sources/open-source.md b/docs/sources/open-source.md index 8d77b0d0..703d6aa5 100644 --- a/docs/sources/open-source.md +++ b/docs/sources/open-source.md @@ -32,7 +32,7 @@ Grafana OnCall Slack integration use a lot of Slack API features: # Choose the unique prefix instead of pretty-turkey-83 # Localtunnel will generate an url, e.g. https://pretty-turkey-83.loca.lt # it is referred as below -lt --port 8000 -s pretty-turkey-83 --print-requests +lt --port 8080 -s pretty-turkey-83 --print-requests ``` 3. If you use localtunnel, open your external URL and click "Continue" to allow requests to bypass the warning page. diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index d4664d4e..d3996c51 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -297,10 +297,10 @@ Seek for such a line: “Your invite token: <> , use it in the Graf label="OnCall backend URL" description={ - It should be rechable from Grafana. Possible options:
- http://host.docker.internal:8000 (if you run backend in the docker locally) + It should be reachable from Grafana. Possible options:
+ http://host.docker.internal:8080 (if you run backend in the docker locally)
- http://localhost:8000
+ http://localhost:8080
...
} From 75a35ad027718502495fb1038ebc8f5921948fd7 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 17:55:31 +0300 Subject: [PATCH 127/132] Fixing port --- docs/sources/open-source.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/open-source.md b/docs/sources/open-source.md index 703d6aa5..fc31bf69 100644 --- a/docs/sources/open-source.md +++ b/docs/sources/open-source.md @@ -17,7 +17,7 @@ We prepared three environments for OSS users: ## Production Environment -TBD +We prepared the helm chart for production environment: https://github.com/grafana/oncall/helm ## Slack Setup From 37af24cdea9ab399c506e9169d6cc1c36a3bac2f Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 17:57:01 +0300 Subject: [PATCH 128/132] Fixing port --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a726318a..b9569831 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ curl https://github.com/grafana/oncall/blob/dev/docker-compose.yml -o docker-com 2. Set variables: ```bash -echo "DOMAIN=http://localhost +echo "DOMAIN=http://localhost:8080 SECRET_KEY=my_random_secret_must_be_more_than_32_characters_long RABBITMQ_PASSWORD=rabbitmq_secret_pw MYSQL_PASSWORD=mysql_secret_pw From e924e7dea43112530cdd0cd4f8661c6a596525d3 Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 18:02:59 +0300 Subject: [PATCH 129/132] Fixing port --- DEVELOPER.md | 1 + 1 file changed, 1 insertion(+) diff --git a/DEVELOPER.md b/DEVELOPER.md index 08086609..d2bfbc65 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -226,6 +226,7 @@ pytest --ds=settings.dev - Set Settings to settings/dev.py 5. Create a new Django Server run configuration to Run/Debug the engine - Use a plugin such as EnvFile to load the .env file + - Change port from 8000 to 8080 ## Update drone build The .drone.yml build file must be signed when changes are made to it. Follow these steps: From c5f2a4e4d9c3b8c9211ccd775f92bba7348de95f Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 18:03:57 +0300 Subject: [PATCH 130/132] Fixing port --- DEVELOPER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index d2bfbc65..37a0a526 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -64,7 +64,7 @@ python manage.py createsuperuser 3. Launch the backend: ```bash # Http server: -python manage.py runserver +python manage.py runserver 8080 # Worker for background tasks (run it in the parallel terminal, don't forget to export .env there) python manage.py start_celery From 4064822bc4f18a474c13483731c2e42a0bd59f0b Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Tue, 14 Jun 2022 09:05:30 -0600 Subject: [PATCH 131/132] Replace symlink with file for CHANGELOG.MD (#68) Co-authored-by: Michael Derynck --- grafana-plugin/CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) mode change 120000 => 100644 grafana-plugin/CHANGELOG.md diff --git a/grafana-plugin/CHANGELOG.md b/grafana-plugin/CHANGELOG.md deleted file mode 120000 index 04c99a55..00000000 --- a/grafana-plugin/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -../CHANGELOG.md \ No newline at end of file diff --git a/grafana-plugin/CHANGELOG.md b/grafana-plugin/CHANGELOG.md new file mode 100644 index 00000000..8893332c --- /dev/null +++ b/grafana-plugin/CHANGELOG.md @@ -0,0 +1,5 @@ +# Change Log + +## 0.0.71 (2022-06-06) + +- Initial Release \ No newline at end of file From 1b3b0f9e13f2644d3edce0956112097f691c8f9a Mon Sep 17 00:00:00 2001 From: Matvey Kukuy Date: Tue, 14 Jun 2022 18:21:28 +0300 Subject: [PATCH 132/132] Fixing port --- .github/workflows/publish_docs.yml | 42 +++++++++++++++--------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/publish_docs.yml b/.github/workflows/publish_docs.yml index 2eb7dec4..914e9d57 100644 --- a/.github/workflows/publish_docs.yml +++ b/.github/workflows/publish_docs.yml @@ -17,24 +17,24 @@ jobs: - name: Build Website run: | docker run -v ${PWD}/sources:/hugo/content/docs/amixr --rm grafana/docs-base:latest /bin/bash -c 'make hugo' -# sync: -# runs-on: ubuntu-latest -# needs: test -# if: github.ref == 'refs/heads/main' -# steps: -# - uses: actions/checkout@v1 -# - run: git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync -# - name: publish-to-git -# uses: ./.github/actions/website-sync -# id: publish -# with: -# repository: grafana/website -# branch: master -# host: github.com -# github_pat: '${{ secrets.GH_BOT_ACCESS_TOKEN }}' -# source_folder: docs/sources -# target_folder: content/docs/amixr/v0.0.39 -# - shell: bash -# run: | -# test -n "${{ steps.publish.outputs.commit_hash }}" -# test -n "${{ steps.publish.outputs.working_directory }}" + sync: + runs-on: ubuntu-latest + needs: test + if: github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v1 + - run: git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.GH_BOT_ACCESS_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync + - name: publish-to-git + uses: ./.github/actions/website-sync + id: publish + with: + repository: grafana/website + branch: master + host: github.com + github_pat: '${{ secrets.GH_BOT_ACCESS_TOKEN }}' + source_folder: docs/sources + target_folder: content/docs/amixr/v0.0.39 + - shell: bash + run: | + test -n "${{ steps.publish.outputs.commit_hash }}" + test -n "${{ steps.publish.outputs.working_directory }}"