diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..f52d60f --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,42 @@ +{ + "name": "craftoncu/hacs-vikunja-integration", + "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye", + "postCreateCommand": "scripts/setup", + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/rust:1": {} + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..92fe7a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,55 @@ +--- +name: "Bug report" +description: "Report a bug with the integration" +labels: "Bug" +body: +- type: markdown + attributes: + value: Before you open a new issue, search through the existing issues to see if others have had the same problem. +- type: textarea + attributes: + label: "System Health details" + description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" + validations: + required: true +- type: checkboxes + attributes: + label: Checklist + options: + - label: I have enabled debug logging for my installation. + required: true + - label: I have filled out the issue template to the best of my ability. + required: true + - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). + required: true + - label: This issue is not a duplicate issue of any [previous issues](https://github.com/ludeeus/integration_blueprint/issues?q=is%3Aissue+label%3A%22Bug%22+).. + required: true +- type: textarea + attributes: + label: "Describe the issue" + description: "A clear and concise description of what the issue is." + validations: + required: true +- type: textarea + attributes: + label: Reproduction steps + description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." + value: | + 1. + 2. + 3. + ... + validations: + required: true +- type: textarea + attributes: + label: "Debug logs" + description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." + render: text + validations: + required: true + +- type: textarea + attributes: + label: "Diagnostics dump" + description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..433467b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,47 @@ +--- +name: "Feature request" +description: "Suggest an idea for this project" +labels: "Feature+Request" +body: +- type: markdown + attributes: + value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. +- type: checkboxes + attributes: + label: Checklist + options: + - label: I have filled out the template to the best of my ability. + required: true + - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). + required: true + - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/ludeeus/integration_blueprint/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). + required: true + +- type: textarea + attributes: + label: "Is your feature request related to a problem? Please describe." + description: "A clear and concise description of what the problem is." + placeholder: "I'm always frustrated when [...]" + validations: + required: true + +- type: textarea + attributes: + label: "Describe the solution you'd like" + description: "A clear and concise description of what you want to happen." + validations: + required: true + +- type: textarea + attributes: + label: "Describe alternatives you've considered" + description: "A clear and concise description of any alternative solutions or features you've considered." + validations: + required: true + +- type: textarea + attributes: + label: "Additional context" + description: "Add any other context or screenshots about the feature request here." + validations: + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..04f2d40 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + ignore: + # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json + - dependency-name: "homeassistant" \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..25bf6cc --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: "Lint" + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + ruff: + name: "Ruff" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Set up Python" + uses: actions/setup-python@v4.7.1 + with: + python-version: "3.11" + cache: "pip" + + - name: "Install requirements" + run: python3 -m pip install -r requirements.txt + + - name: "Run" + run: python3 -m ruff check . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5f27118 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: "Release" + +on: + release: + types: + - "published" + +permissions: {} + +jobs: + release: + name: "Release" + runs-on: "ubuntu-latest" + permissions: + contents: write + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Adjust version number" + shell: "bash" + run: | + yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ + "${{ github.workspace }}/custom_components/integration_blueprint/manifest.json" + + - name: "ZIP the integration directory" + shell: "bash" + run: | + cd "${{ github.workspace }}/custom_components/integration_blueprint" + zip integration_blueprint.zip -r ./ + + - name: "Upload the ZIP file to the release" + uses: softprops/action-gh-release@v0.1.15 + with: + files: ${{ github.workspace }}/custom_components/integration_blueprint/integration_blueprint.zip diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..3f643d1 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,37 @@ +name: "Validate" + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest + name: "Hassfest Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Run hassfest validation" + uses: "home-assistant/actions/hassfest@master" + + hacs: # https://github.com/hacs/action + name: "HACS Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Run HACS validation" + uses: "hacs/action@main" + with: + category: "integration" + # Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands + ignore: "brands" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e178310 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# artifacts +__pycache__ +.pytest* +*.egg-info +*/build/* +*/dist/* + + +# misc +.coverage +.vscode +coverage.xml +.venv/* + +# Home Assistant configuration +config/* +!config/configuration.yaml diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..7a8331a --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,48 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py310" + +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "C", # complexity + "D", # docstrings + "E", # pycodestyle + "F", # pyflakes/autoflake + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T20", # flake8-print + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be This + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D411", # Missing blank line before section + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +[flake8-pytest-style] +fixture-parentheses = false + +[pyupgrade] +keep-runtime-typing = true + +[mccabe] +max-complexity = 25 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2c3048a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `main`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using `scripts/lint`). +4. Test you contribution. +5. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People *love* thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [black](https://github.com/ambv/black) to make sure the code follows the style. + +## Test your code modification + +This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). + +It comes with development environment in a container, easy to launch +if you use Visual Studio Code. With this container you will have a stand alone +Home Assistant instance running and already configured with the included +[`configuration.yaml`](./config/configuration.yaml) +file. + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9d44b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 - 2024 Daniel Kollmannsberger @craftoncu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bbfcd3 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Hacs Vikunja Integration + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]](LICENSE) + + +**This integration will set up the following platforms.** + +Platform | Description +-- | -- +`todo-List` | One list for every project in Vikunja the user has access to. + + +## Installation + +### Manual Installation + +To manually install this integration, follow these steps: + +1. Copy the contents of this repository into your custom components folder. + +### Installation with HACS + +To install this integration using HACS, follow these steps: + +1. In the HACS UI, search for the project named "hacs-vikunja-integration" or install it via its Git URL. + +## Configuration + +Configuration for this integration is done through the user interface (UI). + +1. Insert the URL of your Vikunja API instance into the API URL field. For example, `https://vikunja.tbd/api/v1`. + +2. Generate an API key inside the Vikunja portal to authenticate the integration. + +## Contributions are welcome! + +If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) + +*** + +[commits-shield]: https://img.shields.io/github/commit-activity/y/craftoncu/integration_blueprint.svg?style=for-the-badge +[commits]: https://github.com/craftoncu/hacs-vikunja-integration/commits/main +[license-shield]: https://img.shields.io/github/license/craftoncu/hacs-vikunja-integration.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/ludeeus/integration_blueprint.svg?style=for-the-badge +[releases]: https://github.com/craftoncu/hacs-vikunja-integration/releases diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..fb6b5a5 --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,9 @@ +# https://www.home-assistant.io/integrations/default_config/ +default_config: + +# https://www.home-assistant.io/integrations/logger/ +logger: + default: debug + logs: + custom_components.hacs_vikunja_integration: debug + homeassistant.components.websocket_api: debug diff --git a/custom_components/hacs_vikunja_integration/__init__.py b/custom_components/hacs_vikunja_integration/__init__.py new file mode 100644 index 0000000..9e019c0 --- /dev/null +++ b/custom_components/hacs_vikunja_integration/__init__.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import HacsVikunjaIntegrationApiClient +from .const import DOMAIN, CONF_API_URL, CONF_API_KEY +from .coordinator import VikunjaDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.TODO] + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + hass.data.setdefault(DOMAIN, {}) + client = HacsVikunjaIntegrationApiClient( + api_url=entry.data[CONF_API_URL], + api_key=entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) + + # Fetch all projects from Vikunja + projects = await client.list_projects() + + # Create a coordinator for each project + for project in projects: + coordinator = VikunjaDataUpdateCoordinator( + hass=hass, + client=client, + project_id=project['id'], + ) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][f"project_{project['id']}"] = coordinator + + + hass.data[DOMAIN][entry.entry_id] = client + + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unloaded + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry.""" + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) diff --git a/custom_components/hacs_vikunja_integration/api.py b/custom_components/hacs_vikunja_integration/api.py new file mode 100644 index 0000000..791627b --- /dev/null +++ b/custom_components/hacs_vikunja_integration/api.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import asyncio +import socket + +import aiohttp +import async_timeout + + +class HacsVikunjaIntegrationApiClientError(Exception): + """Exception to indicate a general API error.""" + + +class HacsVikunjaIntegrationApiClientCommunicationError( + HacsVikunjaIntegrationApiClientError +): + """Exception to indicate a communication error.""" + + +class HacsVikunjaIntegrationApiClientAuthenticationError( + HacsVikunjaIntegrationApiClientError +): + """Exception to indicate an authentication error.""" + + +class HacsVikunjaIntegrationApiClient: + """Vikunja API Client.""" + + def __init__( + self, + api_url: str, + api_key: str, + session: aiohttp.ClientSession, + ) -> None: + """Initialize Vikunja API Client.""" + self._api_url = api_url.rstrip("/") # Remove trailing slash if present + self._api_key = api_key + self._session = session + + async def list_projects(self) -> list[dict[str, any]]: + """List all accessable projects""" + headers = { + "Authorization": f"Bearer {self._api_key}", + "Content-type": "application/json; charset=UTF-8", + } + url = f"{self._api_url}/projects" + return await self._api_wrapper(method="get", url=url, headers=headers) + + async def list_tasks(self, project_id: str) -> list[dict[str, any]]: + """Get all tasks within projects Vikunja.""" + headers = { + "Authorization": f"Bearer {self._api_key}", + "Content-type": "application/json; charset=UTF-8", + } + url = f"{self._api_url}/projects/{project_id}/tasks" + return await self._api_wrapper(method="get", url=url, headers=headers) + + async def update_task(self, task_id: int, done: bool) -> any: + """Update a task's title in Vikunja.""" + headers = { + "Authorization": f"Bearer {self._api_key}", + "Content-type": "application/json; charset=UTF-8", + } + data = {"done": done} + url = f"{self._api_url}/tasks/{task_id}" + return await self._api_wrapper(method="post", url=url, data=data, headers=headers) + + # async def async_vikunja_delete_task(self, task_id: int) -> any: + # """Delete a task in Vikunja.""" + # headers = { + # "Authorization": f"Bearer {self._api_key}", + # "Content-type": "application/json; charset=UTF-8", + # } + # url = f"{self._api_url}/tasks/{task_id}" + # return await self._api_wrapper(method="delete", url=url, headers=headers) + + async def _api_wrapper( + self, + method: str, + url: str, + data: dict | None = None, + headers: dict | None = None, + ) -> any: + """Get information from the API.""" + try: + async with async_timeout.timeout(10): + response = await self._session.request( + method=method, + url=url, + headers=headers, + json=data, + ) + if response.status in (401, 403): + raise HacsVikunjaIntegrationApiClientAuthenticationError( + "Invalid credentials", + ) + response.raise_for_status() + return await response.json() + + except asyncio.TimeoutError as exception: + raise HacsVikunjaIntegrationApiClientCommunicationError( + "Timeout error fetching information", + ) from exception + except (aiohttp.ClientError, socket.gaierror) as exception: + raise HacsVikunjaIntegrationApiClientCommunicationError( + "Error fetching information", + ) from exception + except Exception as exception: # pylint: disable=broad-except + raise HacsVikunjaIntegrationApiClientError( + "Something really wrong happened!" + ) from exception \ No newline at end of file diff --git a/custom_components/hacs_vikunja_integration/config_flow.py b/custom_components/hacs_vikunja_integration/config_flow.py new file mode 100644 index 0000000..4fcc93e --- /dev/null +++ b/custom_components/hacs_vikunja_integration/config_flow.py @@ -0,0 +1,79 @@ +"""Adds config flow for Vikunja integration.""" +from __future__ import annotations + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .api import ( + HacsVikunjaIntegrationApiClient, + HacsVikunjaIntegrationApiClientAuthenticationError, + HacsVikunjaIntegrationApiClientCommunicationError, + HacsVikunjaIntegrationApiClientError, +) +from .const import DOMAIN, LOGGER, CONF_API_URL, CONF_API_KEY + + +class VikunjaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Vikunja.""" + + VERSION = 1 + + async def async_step_user( + self, + user_input: dict | None = None, + ) -> config_entries.FlowResult: + """Handle a flow initialized by the user.""" + _errors = {} + if user_input is not None: + try: + await self._test_credentials( + api_url=user_input[CONF_API_URL], + api_key=user_input[CONF_API_KEY], + ) + except HacsVikunjaIntegrationApiClientAuthenticationError as exception: + LOGGER.warning(exception) + _errors["base"] = "auth" + except HacsVikunjaIntegrationApiClientCommunicationError as exception: + LOGGER.error(exception) + _errors["base"] = "connection" + except HacsVikunjaIntegrationApiClientError as exception: + LOGGER.exception(exception) + _errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_API_URL], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_API_URL, + default=(user_input or {}).get(CONF_API_URL), + ): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.TEXT + ), + ), + vol.Required(CONF_API_KEY): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.PASSWORD + ), + ), + } + ), + errors=_errors, + ) + + async def _test_credentials(self, api_url: str, api_key: str) -> None: + """Validate credentials.""" + client = HacsVikunjaIntegrationApiClient( + api_url=api_url, + api_key=api_key, + session=async_create_clientsession(self.hass), + ) + await client.list_projects() diff --git a/custom_components/hacs_vikunja_integration/const.py b/custom_components/hacs_vikunja_integration/const.py new file mode 100644 index 0000000..a6a8ebe --- /dev/null +++ b/custom_components/hacs_vikunja_integration/const.py @@ -0,0 +1,9 @@ +from logging import Logger, getLogger + +LOGGER: Logger = getLogger(__package__) + +NAME = "HACS Vikunja Integration" +DOMAIN = "hacs_vikunja_integration" +VERSION = "0.0.1" +CONF_API_URL = "api_url" +CONF_API_KEY = "api_key" \ No newline at end of file diff --git a/custom_components/hacs_vikunja_integration/coordinator.py b/custom_components/hacs_vikunja_integration/coordinator.py new file mode 100644 index 0000000..9623122 --- /dev/null +++ b/custom_components/hacs_vikunja_integration/coordinator.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) +from homeassistant.exceptions import ConfigEntryAuthFailed + +from .api import ( + HacsVikunjaIntegrationApiClient, + HacsVikunjaIntegrationApiClientAuthenticationError, + HacsVikunjaIntegrationApiClientError, +) +from .const import DOMAIN, LOGGER + + +class VikunjaDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the Vikunja API.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: HacsVikunjaIntegrationApiClient, + project_id: str + ) -> None: + """Initialize.""" + self.client = client + self._project_id = project_id + super().__init__( + hass=hass, + logger=LOGGER, + name=f"Vikunja Tasks {project_id}", + update_interval=timedelta(minutes=5), + ) + + async def _async_update_data(self) -> list[dict[str, any]]: + """Update data via Vikunja API.""" + try: + return await self.client.list_tasks(self._project_id) + except HacsVikunjaIntegrationApiClientAuthenticationError as exception: + raise ConfigEntryAuthFailed(exception) from exception + except HacsVikunjaIntegrationApiClientError as exception: + raise UpdateFailed(exception) from exception diff --git a/custom_components/hacs_vikunja_integration/manifest.json b/custom_components/hacs_vikunja_integration/manifest.json new file mode 100644 index 0000000..937048a --- /dev/null +++ b/custom_components/hacs_vikunja_integration/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "hacs_vikunja_integration", + "name": "HACS Vikunja Integration", + "codeowners": [ + "@craftoncu" + ], + "config_flow": true, + "documentation": "https://github.com/craftoncu/hacs-vikunja-integration", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/craftoncu/hacs-vikunja-integration/issues", + "version": "0.0.1" +} \ No newline at end of file diff --git a/custom_components/hacs_vikunja_integration/todo.py b/custom_components/hacs_vikunja_integration/todo.py new file mode 100644 index 0000000..8fb5f6e --- /dev/null +++ b/custom_components/hacs_vikunja_integration/todo.py @@ -0,0 +1,160 @@ +"""Vikunja platform.""" +from __future__ import annotations + +from datetime import date, datetime, timedelta +from typing import Any, cast + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from .api import HacsVikunjaIntegrationApiClient +from .const import DOMAIN +from .coordinator import VikunjaDataUpdateCoordinator + +SCAN_INTERVAL = timedelta(minutes=15) + +TODO_STATUS_MAP = { + "needsAction": TodoItemStatus.NEEDS_ACTION, # probably not_started + "completed": TodoItemStatus.COMPLETED, +} +TODO_STATUS_MAP_INV = {v: k for k, v in TODO_STATUS_MAP.items()} + + +def _convert_todo_item(item: TodoItem) -> dict[str, str | None]: + """Convert TodoItem dataclass items to dictionary of attributes the tasks API.""" + result: dict[str, str | None] = {} + result["title"] = item.summary + if item.status is not None: + result["status"] = TODO_STATUS_MAP_INV[item.status] + else: + result["status"] = TodoItemStatus.NEEDS_ACTION + if (due := item.due) is not None: + # due API field is a timestamp string, but with only date resolution + result["due"] = dt_util.start_of_local_day(due).isoformat() + else: + result["due"] = None + result["notes"] = item.description + return result + + +def _convert_api_item(item: dict[str, str]) -> TodoItem: + """Convert tasks API items into a TodoItem.""" + due: date | None = None + if (due_str := item.get("due")) is not None: + due = datetime.fromisoformat(due_str).date() + return TodoItem( + summary=item["title"], + uid=item["id"], + status=TODO_STATUS_MAP.get( + item.get("status", ""), + TodoItemStatus.NEEDS_ACTION, + ), + due=due, + description=item.get("notes"), + ) + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tasks todo platform.""" + api: HacsVikunjaIntegrationApiClient = hass.data[DOMAIN][entry.entry_id] + projects = await api.list_projects() + async_add_entities( + ( + VikunjaTaskTodoListEntity( + VikunjaDataUpdateCoordinator(hass, api, project["id"]), + project["title"], + entry.entry_id, + project["id"], + ) + for project in projects + ), + True, + ) + +class VikunjaTaskTodoListEntity( + CoordinatorEntity[VikunjaDataUpdateCoordinator], TodoListEntity +): + """A To-do List representation of the Shopping List.""" + + _attr_has_entity_name = True + _attr_supported_features = ( +# TodoListEntityFeature.CREATE_TODO_ITEM + TodoListEntityFeature.UPDATE_TODO_ITEM +# | TodoListEntityFeature.DELETE_TODO_ITEM +# | TodoListEntityFeature.MOVE_TODO_ITEM +# | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM +# | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + ) + + def __init__( + self, + coordinator: VikunjaDataUpdateCoordinator, + name: str, + config_entry_id: str, + project_id: str, + ) -> None: + """Initialize LocalTodoListEntity.""" + super().__init__(coordinator) + self._attr_name = name.capitalize() + self._attr_unique_id = f"{config_entry_id}-{project_id}" + self._project_id = project_id + + @property + def todo_items(self) -> list[TodoItem] | None: + """Get the current set of To-do items.""" + if self.coordinator.data is None: + return None + return [_convert_api_item(item) for item in self.coordinator.data] + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + print("Running task update") + uid: str = cast(str, item.uid) + await self.coordinator.client.update_task( + self._project_id, + uid, + item.status == TodoItemStatus.COMPLETED, + ) + await self.coordinator.async_refresh() + + # async def async_create_todo_item(self, item: TodoItem) -> None: + # """Add an item to the To-do list.""" + # await self.coordinator.api.insert( + # self._project_id, + # task=_convert_todo_item(item), + # ) + # await self.coordinator.async_refresh() + + # async def async_delete_todo_items(self, uids: list[str]) -> None: + # """Delete To-do items.""" + # await self.coordinator.api.delete(self._task_list_id, uids) + # await self.coordinator.async_refresh() + + # async def async_move_todo_item( + # self, uid: str, previous_uid: str | None = None + # ) -> None: + # """Re-order a To-do item.""" + # await self.coordinator.api.move(self._task_list_id, uid, previous=previous_uid) + # await self.coordinator.async_refresh() + +# def _order_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: +# """Order the task items response. + +# All tasks have an order amongst their sibblings based on position. + +# Home Assistant To-do items do not support the Google Task parent/sibbling +# relationships and the desired behavior is for them to be filtered. +# """ +# parents = [task for task in tasks if task.get("parent") is None] +# parents.sort(key=lambda task: task["position"]) +# return parents \ No newline at end of file diff --git a/custom_components/hacs_vikunja_integration/translations/en.json b/custom_components/hacs_vikunja_integration/translations/en.json new file mode 100644 index 0000000..5673691 --- /dev/null +++ b/custom_components/hacs_vikunja_integration/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "description": "If you need help with the configuration have a look here: https://github.com/craftoncu/hacs-vikunja-integration", + "data": { + "api_key": "API-Key", + "api_url": "Vikunja-API Address" + } + } + }, + "error": { + "auth": "Username/Password is wrong.", + "connection": "Unable to connect to the server.", + "unknown": "Unknown error occurred." + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..cd8c07a --- /dev/null +++ b/hacs.json @@ -0,0 +1,8 @@ +{ + "name": "HACS Vikunja Integration", + "filename": "hacs_vikunja_integration.zip", + "hide_default_branch": true, + "homeassistant": "2024.1.5", + "render_readme": true, + "zip_release": true +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d551a0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +colorlog==6.7.0 +homeassistant==2024.1.5 +pip>=21.0,<23.2 +ruff==0.0.292 diff --git a/scripts/develop b/scripts/develop new file mode 100644 index 0000000..89eda50 --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/integration_blueprint +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/scripts/lint b/scripts/lint new file mode 100644 index 0000000..9b5b1df --- /dev/null +++ b/scripts/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff check . --fix diff --git a/scripts/setup b/scripts/setup new file mode 100644 index 0000000..141d19f --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt