From 3cb844f22c46bdcaf177ab2fcdc4852f3568c98d Mon Sep 17 00:00:00 2001
From: Chris Caron <lead2gold@gmail.com>
Date: Mon, 14 Oct 2019 18:53:59 -0400
Subject: [PATCH] Add Apprise notification integration (#26868)

* Added apprise notification component

* flake-8 fixes; black formatting + import merged to 1 line

* pylint issues resolved

* added github name to manifest.json

* import moved to top as per code review request

* manifest formatting to avoid failing ci

* .coveragerc updated to include apprise

* removed block for written tests

* more test coverage

* formatting as per code review

* tests converted to async style as per code review

* increased coverage

* bumped version of apprise to 0.8.1

* test that mocked entries are called

* added tests for hass.service loading

* support tags for those who identify the TARGET option

* renamed variable as per code review

* 'assert not' used instead of 'is False'

* added period (in case linter isn't happy)
---
 CODEOWNERS                                    |   1 +
 homeassistant/components/apprise/__init__.py  |   1 +
 .../components/apprise/manifest.json          |  12 ++
 homeassistant/components/apprise/notify.py    |  73 +++++++++
 requirements_all.txt                          |   3 +
 requirements_test_all.txt                     |   3 +
 tests/components/apprise/__init__.py          |   1 +
 tests/components/apprise/test_notify.py       | 148 ++++++++++++++++++
 8 files changed, 242 insertions(+)
 create mode 100644 homeassistant/components/apprise/__init__.py
 create mode 100644 homeassistant/components/apprise/manifest.json
 create mode 100644 homeassistant/components/apprise/notify.py
 create mode 100644 tests/components/apprise/__init__.py
 create mode 100644 tests/components/apprise/test_notify.py

diff --git a/CODEOWNERS b/CODEOWNERS
index ea50d24095c..8e52210cec7 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -26,6 +26,7 @@ homeassistant/components/ambient_station/* @bachya
 homeassistant/components/androidtv/* @JeffLIrion
 homeassistant/components/apache_kafka/* @bachya
 homeassistant/components/api/* @home-assistant/core
+homeassistant/components/apprise/* @caronc
 homeassistant/components/aprs/* @PhilRW
 homeassistant/components/arcam_fmj/* @elupus
 homeassistant/components/arduino/* @fabaff
diff --git a/homeassistant/components/apprise/__init__.py b/homeassistant/components/apprise/__init__.py
new file mode 100644
index 00000000000..6ffdaf690d9
--- /dev/null
+++ b/homeassistant/components/apprise/__init__.py
@@ -0,0 +1 @@
+"""The apprise component."""
diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json
new file mode 100644
index 00000000000..3e971a96e7e
--- /dev/null
+++ b/homeassistant/components/apprise/manifest.json
@@ -0,0 +1,12 @@
+{
+  "domain": "apprise",
+  "name": "Apprise",
+  "documentation": "https://www.home-assistant.io/components/apprise",
+  "requirements": [
+    "apprise==0.8.1"
+  ],
+  "dependencies": [],
+  "codeowners": [
+    "@caronc"
+  ]
+}
diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py
new file mode 100644
index 00000000000..662cc9c1ab6
--- /dev/null
+++ b/homeassistant/components/apprise/notify.py
@@ -0,0 +1,73 @@
+"""Apprise platform for notify component."""
+import logging
+
+import voluptuous as vol
+
+import apprise
+
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (
+    ATTR_TARGET,
+    ATTR_TITLE,
+    ATTR_TITLE_DEFAULT,
+    PLATFORM_SCHEMA,
+    BaseNotificationService,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FILE = "config"
+CONF_URL = "url"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
+    {
+        vol.Optional(CONF_URL): vol.All(cv.ensure_list, [str]),
+        vol.Optional(CONF_FILE): cv.string,
+    }
+)
+
+
+def get_service(hass, config, discovery_info=None):
+    """Get the Apprise notification service."""
+
+    # Create our object
+    a_obj = apprise.Apprise()
+
+    if config.get(CONF_FILE):
+        # Sourced from a Configuration File
+        a_config = apprise.AppriseConfig()
+        if not a_config.add(config[CONF_FILE]):
+            _LOGGER.error("Invalid Apprise config url provided")
+            return None
+
+        if not a_obj.add(a_config):
+            _LOGGER.error("Invalid Apprise config url provided")
+            return None
+
+    if config.get(CONF_URL):
+        # Ordered list of URLs
+        if not a_obj.add(config[CONF_URL]):
+            _LOGGER.error("Invalid Apprise URL(s) supplied")
+            return None
+
+    return AppriseNotificationService(a_obj)
+
+
+class AppriseNotificationService(BaseNotificationService):
+    """Implement the notification service for Apprise."""
+
+    def __init__(self, a_obj):
+        """Initialize the service."""
+        self.apprise = a_obj
+
+    def send_message(self, message="", **kwargs):
+        """Send a message to a specified target.
+
+        If no target/tags are specified, then services are notified as is
+        However, if any tags are specified, then they will be applied
+        to the notification causing filtering (if set up that way).
+        """
+        targets = kwargs.get(ATTR_TARGET)
+        title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+        self.apprise.notify(body=message, title=title, tag=targets)
diff --git a/requirements_all.txt b/requirements_all.txt
index dbc94de9a1c..ef07a3f44b7 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -217,6 +217,9 @@ apcaccess==0.0.13
 # homeassistant.components.apns
 apns2==0.3.0
 
+# homeassistant.components.apprise
+apprise==0.8.1
+
 # homeassistant.components.aprs
 aprslib==0.6.46
 
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 18970fcbac0..967943894fb 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -103,6 +103,9 @@ androidtv==0.0.30
 # homeassistant.components.apns
 apns2==0.3.0
 
+# homeassistant.components.apprise
+apprise==0.8.1
+
 # homeassistant.components.aprs
 aprslib==0.6.46
 
diff --git a/tests/components/apprise/__init__.py b/tests/components/apprise/__init__.py
new file mode 100644
index 00000000000..ffebc35b4e1
--- /dev/null
+++ b/tests/components/apprise/__init__.py
@@ -0,0 +1 @@
+"""Tests for the apprise component."""
diff --git a/tests/components/apprise/test_notify.py b/tests/components/apprise/test_notify.py
new file mode 100644
index 00000000000..237f99de676
--- /dev/null
+++ b/tests/components/apprise/test_notify.py
@@ -0,0 +1,148 @@
+"""The tests for the apprise notification platform."""
+from unittest.mock import patch
+from unittest.mock import MagicMock
+
+from homeassistant.setup import async_setup_component
+
+BASE_COMPONENT = "notify"
+
+
+async def test_apprise_config_load_fail01(hass):
+    """Test apprise configuration failures 1."""
+
+    config = {
+        BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": "/path/"}
+    }
+
+    with patch("apprise.AppriseConfig.add", return_value=False):
+        assert await async_setup_component(hass, BASE_COMPONENT, config)
+        await hass.async_block_till_done()
+
+        # Test that our service failed to load
+        assert not hass.services.has_service(BASE_COMPONENT, "test")
+
+
+async def test_apprise_config_load_fail02(hass):
+    """Test apprise configuration failures 2."""
+
+    config = {
+        BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": "/path/"}
+    }
+
+    with patch("apprise.Apprise.add", return_value=False):
+        with patch("apprise.AppriseConfig.add", return_value=True):
+            assert await async_setup_component(hass, BASE_COMPONENT, config)
+            await hass.async_block_till_done()
+
+            # Test that our service failed to load
+            assert not hass.services.has_service(BASE_COMPONENT, "test")
+
+
+async def test_apprise_config_load_okay(hass, tmp_path):
+    """Test apprise configuration failures."""
+
+    # Test cases where our URL is invalid
+    d = tmp_path / "apprise-config"
+    d.mkdir()
+    f = d / "apprise"
+    f.write_text("mailto://user:pass@example.com/")
+
+    config = {BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": str(f)}}
+
+    assert await async_setup_component(hass, BASE_COMPONENT, config)
+    await hass.async_block_till_done()
+
+    # Valid configuration was loaded; our service is good
+    assert hass.services.has_service(BASE_COMPONENT, "test")
+
+
+async def test_apprise_url_load_fail(hass):
+    """Test apprise url failure."""
+
+    config = {
+        BASE_COMPONENT: {
+            "name": "test",
+            "platform": "apprise",
+            "url": "mailto://user:pass@example.com",
+        }
+    }
+    with patch("apprise.Apprise.add", return_value=False):
+        assert await async_setup_component(hass, BASE_COMPONENT, config)
+        await hass.async_block_till_done()
+
+        # Test that our service failed to load
+        assert not hass.services.has_service(BASE_COMPONENT, "test")
+
+
+async def test_apprise_notification(hass):
+    """Test apprise notification."""
+
+    config = {
+        BASE_COMPONENT: {
+            "name": "test",
+            "platform": "apprise",
+            "url": "mailto://user:pass@example.com",
+        }
+    }
+
+    # Our Message
+    data = {"title": "Test Title", "message": "Test Message"}
+
+    with patch("apprise.Apprise") as mock_apprise:
+        obj = MagicMock()
+        obj.add.return_value = True
+        obj.notify.return_value = True
+        mock_apprise.return_value = obj
+        assert await async_setup_component(hass, BASE_COMPONENT, config)
+        await hass.async_block_till_done()
+
+        # Test the existance of our service
+        assert hass.services.has_service(BASE_COMPONENT, "test")
+
+        # Test the call to our underlining notify() call
+        await hass.services.async_call(BASE_COMPONENT, "test", data)
+        await hass.async_block_till_done()
+
+        # Validate calls were made under the hood correctly
+        obj.add.assert_called_once_with([config[BASE_COMPONENT]["url"]])
+        obj.notify.assert_called_once_with(
+            **{"body": data["message"], "title": data["title"], "tag": None}
+        )
+
+
+async def test_apprise_notification_with_target(hass, tmp_path):
+    """Test apprise notification with a target."""
+
+    # Test cases where our URL is invalid
+    d = tmp_path / "apprise-config"
+    d.mkdir()
+    f = d / "apprise"
+
+    # Write 2 config entries each assigned to different tags
+    f.write_text("devops=mailto://user:pass@example.com/\r\n")
+    f.write_text("system,alert=syslog://\r\n")
+
+    config = {BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": str(f)}}
+
+    # Our Message, only notify the services tagged with "devops"
+    data = {"title": "Test Title", "message": "Test Message", "target": ["devops"]}
+
+    with patch("apprise.Apprise") as mock_apprise:
+        apprise_obj = MagicMock()
+        apprise_obj.add.return_value = True
+        apprise_obj.notify.return_value = True
+        mock_apprise.return_value = apprise_obj
+        assert await async_setup_component(hass, BASE_COMPONENT, config)
+        await hass.async_block_till_done()
+
+        # Test the existance of our service
+        assert hass.services.has_service(BASE_COMPONENT, "test")
+
+        # Test the call to our underlining notify() call
+        await hass.services.async_call(BASE_COMPONENT, "test", data)
+        await hass.async_block_till_done()
+
+        # Validate calls were made under the hood correctly
+        apprise_obj.notify.assert_called_once_with(
+            **{"body": data["message"], "title": data["title"], "tag": data["target"]}
+        )
-- 
GitLab