Add paperless integration (#145239)

* add paperless integration - config flow and initialisation

* Add first sensors - documents, inbox, storage total and available

* Add status sensors with error attributes

* add status coordinator and organized code

* Fixed None error

* Organized code and moved requests to coordinator

* Organized code

* optimized code

* Add statustype state strings

* Error handling

* Organized code

* Add update sensor and one coordinator for integration

* add sanity sensor and timer for version request

* Add sensors and icons.json. better errorhandling

* Add tests and error handling

* FIxed tests

* Add tests for coverage

* Quality scale

* Stuff

* Improved code structure

* Removed sensor platform and reauth / reconfigure flow

* bump pypaperless to 4.1.0

* Optimized tests; update sensor as update platform; little optimizations

* Code optimizations with update platform

* Add sensor platform

* Removed update platform

* quality scale

* removed unused const

* Removed update snapshot; better code

* Changed name of entry

* Fixed bugs

* Minor changes

* Minor changed and renamed sensors

* Sensors to measurement

* Fixed snapshot; test data to json; minor changes

* removed mypy errors

* Changed translation

* minor changes

* Update homeassistant/components/paperless_ngx/strings.json

---------

Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Florian von Garrel
2025-05-22 12:17:38 +02:00
committed by GitHub
parent a54c8a88ff
commit 9a8c29e05d
24 changed files with 1274 additions and 0 deletions

2
CODEOWNERS generated
View File

@ -1140,6 +1140,8 @@ build.json @home-assistant/supervisor
/tests/components/palazzetti/ @dotvav
/homeassistant/components/panel_custom/ @home-assistant/frontend
/tests/components/panel_custom/ @home-assistant/frontend
/homeassistant/components/paperless_ngx/ @fvgarrel
/tests/components/paperless_ngx/ @fvgarrel
/homeassistant/components/peblar/ @frenck
/tests/components/peblar/ @frenck
/homeassistant/components/peco/ @IceBotYT

View File

@ -0,0 +1,26 @@
"""The Paperless-ngx integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import PaperlessConfigEntry, PaperlessCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: PaperlessConfigEntry) -> bool:
"""Set up Paperless-ngx from a config entry."""
coordinator = PaperlessCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: PaperlessConfigEntry) -> bool:
"""Unload paperless-ngx config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,78 @@
"""Config flow for the Paperless-ngx integration."""
from __future__ import annotations
from typing import Any
from pypaperless import Paperless
from pypaperless.exceptions import (
InitializationError,
PaperlessConnectionError,
PaperlessForbiddenError,
PaperlessInactiveOrDeletedError,
PaperlessInvalidTokenError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): str,
vol.Required(CONF_API_KEY): str,
}
)
class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Paperless-ngx."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is not None:
self._async_abort_entries_match(
{
CONF_URL: user_input[CONF_URL],
CONF_API_KEY: user_input[CONF_API_KEY],
}
)
errors: dict[str, str] = {}
if user_input is not None:
client = Paperless(
user_input[CONF_URL],
user_input[CONF_API_KEY],
session=async_get_clientsession(self.hass),
)
try:
await client.initialize()
await client.statistics()
except PaperlessConnectionError:
errors[CONF_URL] = "cannot_connect"
except PaperlessInvalidTokenError:
errors[CONF_API_KEY] = "invalid_api_key"
except PaperlessInactiveOrDeletedError:
errors[CONF_API_KEY] = "user_inactive_or_deleted"
except PaperlessForbiddenError:
errors[CONF_API_KEY] = "forbidden"
except InitializationError:
errors[CONF_URL] = "cannot_connect"
except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected exception: %s", err)
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=user_input[CONF_URL], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@ -0,0 +1,7 @@
"""Constants for the Paperless-ngx integration."""
import logging
DOMAIN = "paperless_ngx"
LOGGER = logging.getLogger(__package__)

View File

@ -0,0 +1,109 @@
"""Paperless-ngx Status coordinator."""
from __future__ import annotations
from datetime import timedelta
from pypaperless import Paperless
from pypaperless.exceptions import (
InitializationError,
PaperlessConnectionError,
PaperlessForbiddenError,
PaperlessInactiveOrDeletedError,
PaperlessInvalidTokenError,
)
from pypaperless.models import Statistic
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
type PaperlessConfigEntry = ConfigEntry[PaperlessCoordinator]
UPDATE_INTERVAL = 120
class PaperlessCoordinator(DataUpdateCoordinator[Statistic]):
"""Coordinator to manage Paperless-ngx statistic updates."""
config_entry: PaperlessConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: PaperlessConfigEntry,
) -> None:
"""Initialize my coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=entry,
name="Paperless-ngx Coordinator",
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
self.api = Paperless(
entry.data[CONF_URL],
entry.data[CONF_API_KEY],
session=async_get_clientsession(self.hass),
)
async def _async_setup(self) -> None:
try:
await self.api.initialize()
await self.api.statistics() # test permissions on api
except PaperlessConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except PaperlessInvalidTokenError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from err
except PaperlessInactiveOrDeletedError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="user_inactive_or_deleted",
) from err
except PaperlessForbiddenError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="forbidden",
) from err
except InitializationError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
async def _async_update_data(self) -> Statistic:
"""Fetch data from API endpoint."""
try:
return await self.api.statistics()
except PaperlessConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except PaperlessForbiddenError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="forbidden",
) from err
except PaperlessInvalidTokenError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from err
except PaperlessInactiveOrDeletedError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="user_inactive_or_deleted",
) from err

View File

@ -0,0 +1,34 @@
"""Paperless-ngx base entity."""
from __future__ import annotations
from homeassistant.components.sensor import EntityDescription
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import PaperlessCoordinator
class PaperlessEntity(CoordinatorEntity[PaperlessCoordinator]):
"""Defines a base Paperless-ngx entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: PaperlessCoordinator,
description: EntityDescription,
) -> None:
"""Initialize the Paperless-ngx entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
manufacturer="Paperless-ngx",
sw_version=coordinator.api.host_version,
configuration_url=coordinator.api.base_url,
)

View File

@ -0,0 +1,24 @@
{
"entity": {
"sensor": {
"documents_total": {
"default": "mdi:file-document-multiple"
},
"documents_inbox": {
"default": "mdi:tray-full"
},
"characters_count": {
"default": "mdi:alphabet-latin"
},
"tag_count": {
"default": "mdi:tag"
},
"correspondent_count": {
"default": "mdi:account-group"
},
"document_type_count": {
"default": "mdi:format-list-bulleted-type"
}
}
}
}

View File

@ -0,0 +1,12 @@
{
"domain": "paperless_ngx",
"name": "Paperless-ngx",
"codeowners": ["@fvgarrel"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/paperless_ngx",
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["pypaperless"],
"quality_scale": "bronze",
"requirements": ["pypaperless==4.1.0"]
}

View File

@ -0,0 +1,78 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register actions yet.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register actions yet.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not register custom events yet.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register actions yet.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options flow yet
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Paperless does not support discovery.
discovery:
status: exempt
comment: Paperless does not support discovery.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: Service type integration
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: Service type integration
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@ -0,0 +1,94 @@
"""Sensor platform for Paperless-ngx."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pypaperless.models import Statistic
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PaperlessConfigEntry
from .entity import PaperlessEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class PaperlessEntityDescription(SensorEntityDescription):
"""Describes Paperless-ngx sensor entity."""
value_fn: Callable[[Statistic], int | None]
SENSOR_DESCRIPTIONS: tuple[PaperlessEntityDescription, ...] = (
PaperlessEntityDescription(
key="documents_total",
translation_key="documents_total",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.documents_total,
),
PaperlessEntityDescription(
key="documents_inbox",
translation_key="documents_inbox",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.documents_inbox,
),
PaperlessEntityDescription(
key="characters_count",
translation_key="characters_count",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.character_count,
),
PaperlessEntityDescription(
key="tag_count",
translation_key="tag_count",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.tag_count,
),
PaperlessEntityDescription(
key="correspondent_count",
translation_key="correspondent_count",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.correspondent_count,
),
PaperlessEntityDescription(
key="document_type_count",
translation_key="document_type_count",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.document_type_count,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PaperlessConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Paperless-ngx sensors."""
async_add_entities(
PaperlessSensor(
coordinator=entry.runtime_data,
description=sensor_description,
)
for sensor_description in SENSOR_DESCRIPTIONS
)
class PaperlessSensor(PaperlessEntity, SensorEntity):
"""Defines a Paperless-ngx sensor entity."""
entity_description: PaperlessEntityDescription
@property
def native_value(self) -> int | None:
"""Return the current value of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -0,0 +1,72 @@
{
"config": {
"step": {
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"url": "URL to connect to the Paperless-ngx instance",
"api_key": "API key to connect to the Paperless-ngx API"
},
"title": "Add Paperless-ngx instance"
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::invalid_host%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"user_inactive_or_deleted": "Authentication failed. The user is inactive or has been deleted.",
"forbidden": "The token does not have permission to access the API.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
},
"entity": {
"sensor": {
"documents_total": {
"name": "Total documents",
"unit_of_measurement": "documents"
},
"documents_inbox": {
"name": "Documents in inbox",
"unit_of_measurement": "[%key:component::paperless_ngx::entity::sensor::documents_total::unit_of_measurement%]"
},
"characters_count": {
"name": "Total characters",
"unit_of_measurement": "characters"
},
"tag_count": {
"name": "Tags",
"unit_of_measurement": "tags"
},
"correspondent_count": {
"name": "Correspondents",
"unit_of_measurement": "correspondents"
},
"document_type_count": {
"name": "Document types",
"unit_of_measurement": "document types"
}
}
},
"exceptions": {
"cannot_connect": {
"message": "[%key:common::config_flow::error::invalid_host%]"
},
"invalid_api_key": {
"message": "[%key:common::config_flow::error::invalid_api_key%]"
},
"user_inactive_or_deleted": {
"message": "[%key:component::paperless_ngx::config::error::user_inactive_or_deleted%]"
},
"forbidden": {
"message": "[%key:component::paperless_ngx::config::error::forbidden%]"
},
"unknown": {
"message": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@ -469,6 +469,7 @@ FLOWS = {
"p1_monitor",
"palazzetti",
"panasonic_viera",
"paperless_ngx",
"peblar",
"peco",
"pegel_online",

View File

@ -4848,6 +4848,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
"paperless_ngx": {
"name": "Paperless-ngx",
"integration_type": "service",
"config_flow": true,
"iot_class": "local_polling"
},
"pcs_lighting": {
"name": "PCS Lighting",
"integration_type": "virtual",

3
requirements_all.txt generated
View File

@ -2226,6 +2226,9 @@ pyownet==0.10.0.post1
# homeassistant.components.palazzetti
pypalazzetti==0.1.19
# homeassistant.components.paperless_ngx
pypaperless==4.1.0
# homeassistant.components.elv
pypca==0.0.7

View File

@ -1823,6 +1823,9 @@ pyownet==0.10.0.post1
# homeassistant.components.palazzetti
pypalazzetti==0.1.19
# homeassistant.components.paperless_ngx
pypaperless==4.1.0
# homeassistant.components.lcn
pypck==0.8.6

View File

@ -0,0 +1,14 @@
"""Tests for the Paperless-ngx integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Set up the Paperless-ngx integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

View File

@ -0,0 +1,76 @@
"""Common fixtures for the Paperless-ngx tests."""
from collections.abc import Generator
import json
from unittest.mock import AsyncMock, MagicMock, patch
from pypaperless.models import Statistic
import pytest
from homeassistant.components.paperless_ngx.const import DOMAIN
from homeassistant.core import HomeAssistant
from . import setup_integration
from .const import USER_INPUT
from tests.common import MockConfigEntry, load_fixture
@pytest.fixture
def mock_statistic_data() -> Generator[MagicMock]:
"""Return test statistic data."""
return json.loads(load_fixture("test_data_statistic.json", DOMAIN))
@pytest.fixture
def mock_statistic_data_update() -> Generator[MagicMock]:
"""Return updated test statistic data."""
return json.loads(load_fixture("test_data_statistic_update.json", DOMAIN))
@pytest.fixture(autouse=True)
def mock_paperless(mock_statistic_data: MagicMock) -> Generator[AsyncMock]:
"""Mock the pypaperless.Paperless client."""
with (
patch(
"homeassistant.components.paperless_ngx.coordinator.Paperless",
autospec=True,
) as paperless_mock,
patch(
"homeassistant.components.paperless_ngx.config_flow.Paperless",
new=paperless_mock,
),
):
paperless = paperless_mock.return_value
paperless.base_url = "http://paperless.example.com/"
paperless.host_version = "2.3.0"
paperless.initialize.return_value = None
paperless.statistics = AsyncMock(
return_value=Statistic.create_with_data(
paperless, data=mock_statistic_data, fetched=True
)
)
yield paperless
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
entry_id="paperless_ngx_test",
title="Paperless-ngx",
domain=DOMAIN,
data=USER_INPUT,
)
@pytest.fixture
async def init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_paperless: MagicMock
) -> MockConfigEntry:
"""Set up the Paperless-ngx integration for testing."""
await setup_integration(hass, mock_config_entry)
return mock_config_entry

View File

@ -0,0 +1,8 @@
"""Constants for the Paperless NGX integration tests."""
from homeassistant.const import CONF_API_KEY, CONF_URL
USER_INPUT = {
CONF_URL: "https://192.168.69.16:8000",
CONF_API_KEY: "test_token",
}

View File

@ -0,0 +1,16 @@
{
"documents_total": 999,
"documents_inbox": 9,
"inbox_tag": 9,
"inbox_tags": [9],
"document_file_type_counts": [
{ "mime_type": "application/pdf", "mime_type_count": 998 },
{ "mime_type": "image/png", "mime_type_count": 1 }
],
"character_count": 99999,
"tag_count": 99,
"correspondent_count": 99,
"document_type_count": 99,
"storage_path_count": 9,
"current_asn": 99
}

View File

@ -0,0 +1,16 @@
{
"documents_total": 420,
"documents_inbox": 3,
"inbox_tag": 5,
"inbox_tags": [2],
"document_file_type_counts": [
{ "mime_type": "application/pdf", "mime_type_count": 419 },
{ "mime_type": "image/png", "mime_type_count": 1 }
],
"character_count": 324234,
"tag_count": 43,
"correspondent_count": 9659,
"document_type_count": 54656,
"storage_path_count": 6459,
"current_asn": 959
}

View File

@ -0,0 +1,307 @@
# serializer version: 1
# name: test_sensor_platfom[sensor.paperless_ngx_correspondents-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.paperless_ngx_correspondents',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Correspondents',
'platform': 'paperless_ngx',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'correspondent_count',
'unique_id': 'paperless_ngx_test_correspondent_count',
'unit_of_measurement': 'correspondents',
})
# ---
# name: test_sensor_platfom[sensor.paperless_ngx_correspondents-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Paperless-ngx Correspondents',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'correspondents',
}),
'context': <ANY>,
'entity_id': 'sensor.paperless_ngx_correspondents',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '99',
})
# ---
# name: test_sensor_platfom[sensor.paperless_ngx_document_types-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.paperless_ngx_document_types',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Document types',
'platform': 'paperless_ngx',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'document_type_count',
'unique_id': 'paperless_ngx_test_document_type_count',
'unit_of_measurement': 'document types',
})
# ---
# name: test_sensor_platfom[sensor.paperless_ngx_document_types-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Paperless-ngx Document types',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'document types',
}),
'context': <ANY>,
'entity_id': 'sensor.paperless_ngx_document_types',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '99',
})
# ---
# name: test_sensor_platfom[sensor.paperless_ngx_documents_in_inbox-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.paperless_ngx_documents_in_inbox',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Documents in inbox',
'platform': 'paperless_ngx',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'documents_inbox',
'unique_id': 'paperless_ngx_test_documents_inbox',
'unit_of_measurement': 'documents',
})
# ---
# name: test_sensor_platfom[sensor.paperless_ngx_documents_in_inbox-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Paperless-ngx Documents in inbox',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'documents',
}),
'context': <ANY>,
'entity_id': 'sensor.paperless_ngx_documents_in_inbox',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '9',
})
# ---
# name: test_sensor_platfom[sensor.paperless_ngx_tags-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.paperless_ngx_tags',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Tags',
'platform': 'paperless_ngx',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'tag_count',
'unique_id': 'paperless_ngx_test_tag_count',
'unit_of_measurement': 'tags',
})
# ---
# name: test_sensor_platfom[sensor.paperless_ngx_tags-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Paperless-ngx Tags',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'tags',
}),
'context': <ANY>,
'entity_id': 'sensor.paperless_ngx_tags',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '99',
})
# ---
# name: test_sensor_platfom[sensor.paperless_ngx_total_characters-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.paperless_ngx_total_characters',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Total characters',
'platform': 'paperless_ngx',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'characters_count',
'unique_id': 'paperless_ngx_test_characters_count',
'unit_of_measurement': 'characters',
})
# ---
# name: test_sensor_platfom[sensor.paperless_ngx_total_characters-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Paperless-ngx Total characters',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'characters',
}),
'context': <ANY>,
'entity_id': 'sensor.paperless_ngx_total_characters',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '99999',
})
# ---
# name: test_sensor_platfom[sensor.paperless_ngx_total_documents-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.paperless_ngx_total_documents',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Total documents',
'platform': 'paperless_ngx',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'documents_total',
'unique_id': 'paperless_ngx_test_documents_total',
'unit_of_measurement': 'documents',
})
# ---
# name: test_sensor_platfom[sensor.paperless_ngx_total_documents-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Paperless-ngx Total documents',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'documents',
}),
'context': <ANY>,
'entity_id': 'sensor.paperless_ngx_total_documents',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '999',
})
# ---

View File

@ -0,0 +1,112 @@
"""Tests for the Paperless-ngx config flow."""
from collections.abc import Generator
from unittest.mock import AsyncMock
from pypaperless.exceptions import (
InitializationError,
PaperlessConnectionError,
PaperlessForbiddenError,
PaperlessInactiveOrDeletedError,
PaperlessInvalidTokenError,
)
import pytest
from homeassistant import config_entries
from homeassistant.components.paperless_ngx.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import USER_INPUT
from tests.common import MockConfigEntry, patch
@pytest.fixture(autouse=True)
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.paperless_ngx.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
async def test_full_config_flow(hass: HomeAssistant) -> None:
"""Test registering an integration and finishing flow works."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["flow_id"]
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
config_entry = result["result"]
assert config_entry.title == USER_INPUT[CONF_URL]
assert result["type"] is FlowResultType.CREATE_ENTRY
assert config_entry.data == USER_INPUT
@pytest.mark.parametrize(
("side_effect", "expected_error"),
[
(PaperlessConnectionError(), {CONF_URL: "cannot_connect"}),
(PaperlessInvalidTokenError(), {CONF_API_KEY: "invalid_api_key"}),
(PaperlessInactiveOrDeletedError(), {CONF_API_KEY: "user_inactive_or_deleted"}),
(PaperlessForbiddenError(), {CONF_API_KEY: "forbidden"}),
(InitializationError(), {CONF_URL: "cannot_connect"}),
(Exception("BOOM!"), {"base": "unknown"}),
],
)
async def test_config_flow_error_handling(
hass: HomeAssistant,
mock_paperless: AsyncMock,
side_effect: Exception,
expected_error: dict[str, str],
) -> None:
"""Test user step shows correct error for various client initialization issues."""
mock_paperless.initialize.side_effect = side_effect
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=USER_INPUT,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == expected_error
mock_paperless.initialize.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=USER_INPUT,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == USER_INPUT[CONF_URL]
assert result["data"] == USER_INPUT
async def test_config_already_exists(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test we only allow a single config flow."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
data=USER_INPUT,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@ -0,0 +1,65 @@
"""Test the Paperless-ngx integration initialization."""
from unittest.mock import AsyncMock
from pypaperless.exceptions import (
InitializationError,
PaperlessConnectionError,
PaperlessForbiddenError,
PaperlessInactiveOrDeletedError,
PaperlessInvalidTokenError,
)
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
async def test_load_unload_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test loading and unloading the integration."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("side_effect", "expected_state", "expected_error_key"),
[
(PaperlessConnectionError(), ConfigEntryState.SETUP_RETRY, None),
(PaperlessInvalidTokenError(), ConfigEntryState.SETUP_ERROR, "invalid_api_key"),
(
PaperlessInactiveOrDeletedError(),
ConfigEntryState.SETUP_ERROR,
"user_inactive_or_deleted",
),
(PaperlessForbiddenError(), ConfigEntryState.SETUP_ERROR, "forbidden"),
(InitializationError(), ConfigEntryState.SETUP_ERROR, "cannot_connect"),
],
)
async def test_setup_config_error_handling(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_paperless: AsyncMock,
side_effect: Exception,
expected_state: ConfigEntryState,
expected_error_key: str,
) -> None:
"""Test all initialization error paths during setup."""
mock_paperless.initialize.side_effect = side_effect
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state == expected_state
assert mock_config_entry.error_reason_translation_key == expected_error_key

View File

@ -0,0 +1,111 @@
"""Tests for Paperless-ngx sensor platform."""
from datetime import timedelta
from freezegun.api import FrozenDateTimeFactory
from pypaperless.exceptions import (
PaperlessConnectionError,
PaperlessForbiddenError,
PaperlessInactiveOrDeletedError,
PaperlessInvalidTokenError,
)
from pypaperless.models import Statistic
import pytest
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import (
AsyncMock,
MockConfigEntry,
SnapshotAssertion,
async_fire_time_changed,
patch,
snapshot_platform,
)
async def test_sensor_platfom(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test paperless_ngx update sensors."""
with patch("homeassistant.components.paperless_ngx.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_statistic_sensor_state(
hass: HomeAssistant,
mock_paperless: AsyncMock,
freezer: FrozenDateTimeFactory,
mock_statistic_data_update,
) -> None:
"""Ensure sensor entities are added automatically."""
# initialize with 999 documents
state = hass.states.get("sensor.paperless_ngx_total_documents")
assert state.state == "999"
# update to 420 documents
mock_paperless.statistics = AsyncMock(
return_value=Statistic.create_with_data(
mock_paperless, data=mock_statistic_data_update, fetched=True
)
)
freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.paperless_ngx_total_documents")
assert state.state == "420"
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"error_cls",
[
PaperlessForbiddenError,
PaperlessConnectionError,
PaperlessInactiveOrDeletedError,
PaperlessInvalidTokenError,
],
)
async def test__statistic_sensor_state_on_error(
hass: HomeAssistant,
mock_paperless: AsyncMock,
freezer: FrozenDateTimeFactory,
mock_statistic_data_update,
error_cls,
) -> None:
"""Ensure sensor entities are added automatically."""
# simulate error
mock_paperless.statistics.side_effect = error_cls
freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.paperless_ngx_total_documents")
assert state.state == STATE_UNAVAILABLE
# recover from error
mock_paperless.statistics = AsyncMock(
return_value=Statistic.create_with_data(
mock_paperless, data=mock_statistic_data_update, fetched=True
)
)
freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.paperless_ngx_total_documents")
assert state.state == "420"