Skip to content

Commit

Permalink
Add install-create event handlers (#608)
Browse files Browse the repository at this point in the history
* Add notifications on install create for slack and osticket

* Fix: admin UI redirects aren't followed correctly

* Formatting

* Fix url typo

* Move address one-line helper to Building

* Gate integrations behind feature flags

* Fix warning in flag decorator

* Add vars for "Add install-create event handlers" (#617)

* add vars

* Add new ticket endpoint variable

---------

Co-authored-by: Andrew Dickinson <[email protected]>

* Add tests for install event hooks

* Formatting

* Fix failing test

* Add slack webhook retries & tests

* Revert "Fix: admin UI redirects aren't followed correctly"

This reverts commit 41d738b.

---------

Co-authored-by: james-otten <[email protected]>
  • Loading branch information
Andrew-Dickinson and james-otten authored Oct 12, 2024
1 parent cc5f067 commit 6b2f203
Show file tree
Hide file tree
Showing 14 changed files with 450 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,8 @@ LOS_URL=https://devlos.mesh.nycmesh.net
FORMS_URL=https://devforms.mesh.nycmesh.net

OSTICKET_URL=https://support.nycmesh.net
OSTICKET_API_TOKEN=
OSTICKET_NEW_TICKET_ENDPOINT=https://devsupport.nycmesh.net/api/http.php/tickets.json

SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL=
SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL=
3 changes: 3 additions & 0 deletions .github/workflows/deploy-to-k8s.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ jobs:
--set meshweb.forms_url="${{ vars.FORMS_URL }}" \
--set meshdb.site_base_url="${{ vars.SITE_BASE_URL }}" \
--set meshweb.slack_webhook="${{ secrets.SLACK_ADMIN_NOTIFICATIONS_WEBHOOK_URL }}" \
--set meshweb.slack_join_webhook="${{ secrets.SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL }}" \
--set meshweb.osticket_api_token="${{ secrets.OSTICKET_API_TOKEN }}" \
--set meshweb.osticket_new_ticket_endpoint="${{ vars.OSTICKET_NEW_TICKET_ENDPOINT }}" \
--set meshweb.environment="${{ inputs.environment }}" \
--set ingress.hosts[0].host="${{ vars.INGRESS_HOST }}",ingress.hosts[0].paths[0].path=/,ingress.hosts[0].paths[0].pathType=Prefix \
--set ingress.hosts[1].host="${{ vars.INGRESS_HOST_LEGACY }}",ingress.hosts[1].paths[0].path=/,ingress.hosts[1].paths[0].pathType=Prefix
Expand Down
2 changes: 2 additions & 0 deletions infra/helm/meshdb/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ data:
FORMS_URL: {{ .Values.meshweb.forms_url | quote }}

SITE_BASE_URL: {{ .Values.meshdb.site_base_url | quote }}

OSTICKET_NEW_TICKET_ENDPOINT: {{ .Values.meshweb.osticket_new_ticket_endpoint | quote }}
10 changes: 10 additions & 0 deletions infra/helm/meshdb/templates/meshweb.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ spec:
secretKeyRef:
name: meshdb-secrets
key: slack-webhook
- name: SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL
valueFrom:
secretKeyRef:
name: meshdb-secrets
key: slack-join-webhook
- name: OSTICKET_API_TOKEN
valueFrom:
secretKeyRef:
name: meshdb-secrets
key: osticket-api-token
volumeMounts:
- name: static-content-vol
mountPath: /opt/meshdb/static
Expand Down
2 changes: 2 additions & 0 deletions infra/helm/meshdb/templates/secrets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ data:
uisp-pass: {{ .Values.uisp.psk | b64enc | quote }}
pano-github-token: {{ .Values.meshweb.pano_github_token | b64enc | quote }}
slack-webhook: {{ .Values.meshweb.slack_webhook | b64enc | quote }}
slack-join-webhook: {{ .Values.meshweb.slack_join_webhook | b64enc | quote }}
osticket-api-token: {{ .Values.meshweb.osticket_api_token | b64enc | quote }}
4 changes: 4 additions & 0 deletions src/meshapi/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@
class MeshapiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "meshapi"

def ready(self) -> None:
# Implicitly connect signal handlers decorated with @receiver.
from meshapi.util import events # noqa: F401
17 changes: 17 additions & 0 deletions src/meshapi/models/building.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,23 @@ def save(self, *args: Any, **kwargs: Any) -> None:
if self.primary_node and self.primary_node not in self.nodes.all():
self.nodes.add(self.primary_node)

@property
def one_line_complete_address(self) -> str:
addr_components = []
if self.street_address:
addr_components.append(self.street_address)
if self.city or self.city:
city_state = []
if self.city:
city_state.append(self.city)
if self.state:
city_state.append(self.state)
addr_components.append(" ".join(city_state))
if self.zip_code:
addr_components.append(self.zip_code)

return ", ".join(addr_components)

def __str__(self) -> str:
if self.street_address:
addr_str = str(self.street_address)
Expand Down
23 changes: 23 additions & 0 deletions src/meshapi/tests/test_building.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from django.test import TestCase

from meshapi.models import Building


class TestBuilding(TestCase):
def test_building_address_single_line_str(self):
full_address_building = Building(
street_address="123 Chom Street",
city="Brooklyn",
state="NY",
zip_code="12345",
latitude=0,
longitude=0,
)
self.assertEqual(full_address_building.one_line_complete_address, "123 Chom Street, Brooklyn NY, 12345")

limited_address_building = Building(
street_address="123 Chom Street",
latitude=0,
longitude=0,
)
self.assertEqual(limited_address_building.one_line_complete_address, "123 Chom Street")
205 changes: 205 additions & 0 deletions src/meshapi/tests/test_install_create_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import json
from unittest.mock import patch

import requests_mock
from django.test import TestCase
from flags.state import disable_flag, enable_flag

from meshapi.models import Building, Install, Member
from meshapi.tests.sample_data import sample_building, sample_install, sample_member


class TestInstallCreateSignals(TestCase):
def setUp(self):
self.sample_install_copy = sample_install.copy()
self.building_1 = Building(**sample_building)
self.building_1.save()
self.sample_install_copy["building"] = self.building_1

self.member = Member(**sample_member)
self.member.save()
self.sample_install_copy["member"] = self.member

self.maxDiff = None

@requests_mock.Mocker()
def test_no_events_happen_by_default(self, request_mocker):
install = Install(**self.sample_install_copy)
install.save()

self.assertEqual(len(request_mocker.request_history), 0)

@patch(
"meshapi.util.events.join_requests_slack_channel.SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL",
"http://example.com/test-url",
)
@requests_mock.Mocker()
def test_constructing_install_triggers_slack_message(self, request_mocker):
request_mocker.post("http://example.com/test-url", text="data")

enable_flag("INTEGRATION_ENABLED_SEND_JOIN_REQUEST_SLACK_MESSAGES")
disable_flag("INTEGRATION_ENABLED_CREATE_OSTICKET_TICKETS")
install = Install(**self.sample_install_copy)
install.save()

self.assertEqual(len(request_mocker.request_history), 1)
self.assertEqual(
request_mocker.request_history[0].url,
"http://example.com/test-url",
)
self.assertEqual(
json.loads(request_mocker.request_history[0].text),
{
"text": f"*<https://www.nycmesh.net/map/nodes/{install.install_number}"
f"|3333 Chom St, Brooklyn NY, 11111>*\n"
f"Altitude not found · Roof access · No LoS Data Available"
},
)

@patch(
"meshapi.util.events.osticket_creation.OSTICKET_NEW_TICKET_ENDPOINT",
"http://example.com/test-url",
)
@patch(
"meshapi.util.events.osticket_creation.OSTICKET_API_TOKEN",
"mock-token",
)
@requests_mock.Mocker()
def test_constructing_install_triggers_osticket(self, request_mocker):
request_mocker.post("http://example.com/test-url", text="00123456", status_code=201)

disable_flag("INTEGRATION_ENABLED_SEND_JOIN_REQUEST_SLACK_MESSAGES")
enable_flag("INTEGRATION_ENABLED_CREATE_OSTICKET_TICKETS")

install = Install(**self.sample_install_copy)
install.save()

self.assertEqual(len(request_mocker.request_history), 1)
self.assertEqual(
request_mocker.request_history[0].url,
"http://example.com/test-url",
)
self.assertEqual(
json.loads(request_mocker.request_history[0].text),
{
"node": install.install_number,
"userNode": install.install_number,
"email": "[email protected]",
"name": "John Smith",
"subject": f"NYC Mesh {install.install_number} Rooftop Install",
"message": f"date: 2022-02-27\r\nnode: {install.install_number}\r\nname: John Smith\r\nemail: [email protected]\r\nphone: +1 555-555-5555\r\nlocation: 3333 Chom St, Brooklyn NY, 11111\r\nrooftop: Rooftop install\r\nagree to ncl: True",
"phone": "+1 555-555-5555",
"location": "3333 Chom St, Brooklyn NY, 11111",
"rooftop": "Rooftop install",
"ncl": True,
"ip": "*.*.*.*",
"locale": "en",
},
)

install.refresh_from_db()
self.assertEqual(install.ticket_number, "00123456")

@patch(
"meshapi.util.events.join_requests_slack_channel.SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL",
"",
)
@patch(
"meshapi.util.events.osticket_creation.OSTICKET_NEW_TICKET_ENDPOINT",
"",
)
@requests_mock.Mocker()
def test_no_events_when_env_variables_unset(self, request_mocker):
enable_flag("INTEGRATION_ENABLED_SEND_JOIN_REQUEST_SLACK_MESSAGES")
enable_flag("INTEGRATION_ENABLED_CREATE_OSTICKET_TICKETS")

install = Install(**self.sample_install_copy)
install.save()

self.assertEqual(len(request_mocker.request_history), 0)

@patch(
"meshapi.util.events.osticket_creation.OSTICKET_NEW_TICKET_ENDPOINT",
"http://example.com/test-url",
)
@patch(
"meshapi.util.events.osticket_creation.OSTICKET_API_TOKEN",
"",
)
@requests_mock.Mocker()
def test_no_osticket_event_when_no_api_token(self, request_mocker):
enable_flag("INTEGRATION_ENABLED_CREATE_OSTICKET_TICKETS")

install = Install(**self.sample_install_copy)
install.save()

self.assertEqual(len(request_mocker.request_history), 0)

@patch(
"meshapi.util.events.join_requests_slack_channel.SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL",
"http://example.com/test-url",
)
@patch(
"meshapi.util.events.osticket_creation.OSTICKET_NEW_TICKET_ENDPOINT",
"http://example.com/test-url",
)
@patch(
"meshapi.util.events.osticket_creation.OSTICKET_API_TOKEN",
"mock-token",
)
@requests_mock.Mocker()
def test_no_events_for_install_edit(self, request_mocker):
install = Install(**self.sample_install_copy)
install.save()

enable_flag("INTEGRATION_ENABLED_SEND_JOIN_REQUEST_SLACK_MESSAGES")
enable_flag("INTEGRATION_ENABLED_CREATE_OSTICKET_TICKETS")

install.notes = "foo"
install.save()

self.assertEqual(len(request_mocker.request_history), 0)

@patch(
"meshapi.util.events.join_requests_slack_channel.SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL",
"http://example.com/test-url-slack",
)
@patch(
"meshapi.util.events.osticket_creation.OSTICKET_NEW_TICKET_ENDPOINT",
"http://example.com/test-url-os-ticket",
)
@patch(
"meshapi.util.events.osticket_creation.OSTICKET_API_TOKEN",
"mock-token",
)
@requests_mock.Mocker()
def test_many_retry_no_crash_on_integration_404(self, request_mocker):
request_mocker.post("http://example.com/test-url-slack", text="Not found", status_code=404)
request_mocker.post("http://example.com/test-url-os-ticket", text="Not found", status_code=404)

enable_flag("INTEGRATION_ENABLED_SEND_JOIN_REQUEST_SLACK_MESSAGES")
enable_flag("INTEGRATION_ENABLED_CREATE_OSTICKET_TICKETS")

install = Install(**self.sample_install_copy)
install.save()

self.assertEqual(
len(
[
request
for request in request_mocker.request_history
if request.url == "http://example.com/test-url-os-ticket"
]
),
4,
)
self.assertEqual(
len(
[
request
for request in request_mocker.request_history
if request.url == "http://example.com/test-url-slack"
]
),
4,
)
22 changes: 22 additions & 0 deletions src/meshapi/util/django_flag_decorator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from functools import wraps
from typing import Any, Callable

from flags.state import flag_state


def skip_if_flag_disabled(flag_name: str) -> Callable:
"""
Decorator that transforms the annotated function into a noop if the given flag name is disabled
:param flag_name: the flag to check
"""

def decorator(func: Callable) -> Callable:
def inner(*args: list, **kwargs: dict) -> Any:
enabled = flag_state(flag_name)

if enabled:
return func(*args, **kwargs)

return wraps(func)(inner)

return decorator
2 changes: 2 additions & 0 deletions src/meshapi/util/events/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .join_requests_slack_channel import send_join_request_slack_message
from .osticket_creation import create_os_ticket_for_install
54 changes: 54 additions & 0 deletions src/meshapi/util/events/join_requests_slack_channel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import logging
import os
import time

import requests
from django.db.models.base import ModelBase
from django.db.models.signals import post_save
from django.dispatch import receiver

from meshapi.models import Install
from meshapi.util.django_flag_decorator import skip_if_flag_disabled

SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL = os.environ.get("SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL")


@receiver(post_save, sender=Install, dispatch_uid="join_requests_slack_channel")
@skip_if_flag_disabled("INTEGRATION_ENABLED_SEND_JOIN_REQUEST_SLACK_MESSAGES")
def send_join_request_slack_message(sender: ModelBase, instance: Install, created: bool, **kwargs: dict) -> None:
if not created:
return

install: Install = instance
if not SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL:
logging.error(
f"Unable to send join request notification for install {str(install)}, did you set the "
f"SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL environment variable?"
)
return

building_height = str(int(install.building.altitude)) + "m" if install.building.altitude else "Altitude not found"
roof_access = "Roof access" if install.roof_access else "No roof access"

attempts = 0
while attempts < 4:
attempts += 1
response = requests.post(
SLACK_JOIN_REQUESTS_CHANNEL_WEBHOOK_URL,
json={
"text": f"*<https://www.nycmesh.net/map/nodes/{install.install_number}"
f"|{install.building.one_line_complete_address}>*\n"
f"{building_height} · {roof_access} · No LoS Data Available"
},
)

if response.status_code == 200:
break

time.sleep(1)

if response.status_code != 200:
logging.error(
f"Got HTTP {response.status_code} while sending install create notification to "
f"join-requests channel. HTTP response was {response.text}"
)
Loading

0 comments on commit 6b2f203

Please sign in to comment.