# decidalo_client Initial Setup Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Set up a Python aiohttp-based client library for the decidalo V3 Import API with auto-generated Pydantic models.

**Architecture:** The package `decidalo_client` contains auto-generated Pydantic models in a `models` subpackage (via datamodel-code-generator from OpenAPI spec) and a hand-written async client class with flat methods. All endpoints use `X-Api-Key` header authentication.

**Tech Stack:** Python 3.11+, aiohttp, pydantic, datamodel-code-generator, pytest, pytest-asyncio, aioresponses

---

## Task 1: Create Branch and Download OpenAPI Spec

**Files:**
- Create: `openapi/v1/swagger.json`

**Step 1: Create the initial-setup branch**

```bash
git checkout -b initial-setup
```

**Step 2: Create openapi directory and download spec**

```bash
mkdir -p openapi/v1
curl -o openapi/v1/swagger.json https://import.decidalo.dev/swagger/v1/swagger.json
```

**Step 3: Verify the spec was downloaded**

```bash
head -20 openapi/v1/swagger.json
```
Expected: JSON content starting with `{"openapi": "3.0.4", ...}`

**Step 4: Commit**

```bash
git add openapi/
git commit -m "chore: add OpenAPI spec for decidalo import API"
```

---

## Task 2: Remove Pre-commit and Clean Up Template Files

**Files:**
- Delete: `.pre-commit-config.yaml`
- Delete: `src/mypackage/` (entire directory)
- Delete: `unittests/test_myclass.py`

**Step 1: Remove pre-commit config**

```bash
rm .pre-commit-config.yaml
```

**Step 2: Remove template package files**

```bash
rm -rf src/mypackage
rm unittests/test_myclass.py
```

**Step 3: Commit**

```bash
git add -A
git commit -m "chore: remove pre-commit and template placeholder code"
```

---

## Task 3: Create Package Structure

**Files:**
- Create: `src/decidalo_client/__init__.py`
- Create: `src/decidalo_client/py.typed`
- Create: `src/decidalo_client/models/__init__.py`

**Step 1: Create decidalo_client package directory**

```bash
mkdir -p src/decidalo_client/models
```

**Step 2: Create `src/decidalo_client/__init__.py`**

```python
"""
decidalo_client - An async Python client for the decidalo V3 Import API.
"""

from decidalo_client.client import DecidaloClient

__all__ = ["DecidaloClient"]
```

**Step 3: Create `src/decidalo_client/py.typed`**

Empty file (marker for PEP 561 type hints support).

**Step 4: Create `src/decidalo_client/models/__init__.py`**

```python
"""
Auto-generated Pydantic models from OpenAPI spec.
Run `tox -e codegen` to regenerate.
"""

from decidalo_client.models._autogenerated import *  # noqa: F401, F403
```

**Step 5: Commit**

```bash
git add src/
git commit -m "feat: create decidalo_client package structure"
```

---

## Task 4: Update pyproject.toml

**Files:**
- Modify: `pyproject.toml`

**Step 1: Update pyproject.toml with full configuration**

Replace entire content with:

```toml
[project]
name = "decidalo_client"
description = "An async Python client for the decidalo V3 Import API"
license = { text = "MIT" }
requires-python = ">=3.11"
authors = [{ name = "Hochfrequenz Unternehmensberatung GmbH", email = "info@hochfrequenz.de" }]
keywords = ["decidalo", "api", "client", "async", "aiohttp", "pydantic"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Environment :: Console",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
    "Programming Language :: Python",
    "Programming Language :: Python :: 3 :: Only",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Programming Language :: Python :: 3.14",
]
dependencies = [
    "aiohttp>=3.10",
    "pydantic>=2.5",
]
dynamic = ["readme", "version"]

[project.optional-dependencies]
tests = [
    "pytest==8.3.5",
    "pytest-asyncio==0.25.0",
    "aioresponses==0.7.8",
]
linting = [
    "pylint==4.0.4",
]
type_check = [
    "mypy==1.19.1",
    "mypy[pydantic]",
]
spell_check = [
    "codespell==2.4.1",
]
coverage = [
    "coverage==7.13.1",
]
formatting = [
    "black==25.12.0",
    "isort==7.0.0",
]
packaging = [
    "build==1.4.0",
    "twine==6.2.0",
]
codegen = [
    "datamodel-code-generator==0.28.5",
]
dev = [
    "pip-tools",
]

[project.urls]
Changelog = "https://github.com/Hochfrequenz/decidalo_client.py/releases"
Homepage = "https://github.com/Hochfrequenz/decidalo_client.py"

[tool.black]
line-length = 120
target_version = ["py311", "py312", "py313", "py314"]

[tool.isort]
line_length = 120
profile = "black"

[tool.pylint."MESSAGES CONTROL"]
max-line-length = 120

[mypy]
truethy-bool = true

[tool.mypy]
disable_error_code = []
plugins = ["pydantic.mypy"]

[[tool.mypy.overrides]]
module = "decidalo_client.models._autogenerated"
ignore_errors = true

[build-system]
requires = ["hatchling>=1.8.0", "hatch-vcs", "hatch-fancy-pypi-readme"]
build-backend = "hatchling.build"

[tool.hatch.metadata.hooks.fancy-pypi-readme]
content-type = "text/markdown"
fragments = [{ path = "README.md" }]

[tool.hatch.version]
source = "vcs"

[tool.hatch.build.hooks.vcs]
version-file = "src/_decidalo_client_version.py"
template = '''
version = "{version}"
'''

[tool.hatch.build.targets.sdist]
exclude = ["/unittests"]

[tool.hatch.build.targets.wheel]
only-include = ["src"]
sources = ["src"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
```

**Step 2: Commit**

```bash
git add pyproject.toml
git commit -m "chore: configure pyproject.toml for decidalo_client"
```

---

## Task 5: Update tox.ini

**Files:**
- Modify: `tox.ini`

**Step 1: Update tox.ini with codegen environment and linting ignores**

Replace entire content with:

```ini
[tox]
envlist =
    tests
    linting
    coverage
    type_check
skip_missing_interpreters = True
skipsdist = True

[testenv]
commands = python -m pip install --upgrade pip

[testenv:tests]
deps =
    -r requirements.txt
    .[tests]
setenv = PYTHONPATH = {toxinidir}/src
commands = python -m pytest --basetemp={envtmpdir} {posargs}

[testenv:linting]
deps =
    {[testenv:tests]deps}
    .[linting]
setenv = PYTHONPATH = {toxinidir}/src
commands =
    pylint decidalo_client --ignore=_autogenerated.py
    pylint unittests --rcfile=unittests/.pylintrc

[testenv:type_check]
setenv = PYTHONPATH = {toxinidir}/src
deps =
    {[testenv:tests]deps}
    .[type_check]
commands =
    mypy --show-error-codes src/decidalo_client --strict --ignore-missing-imports
    mypy --show-error-codes unittests --strict --ignore-missing-imports

[testenv:spell_check]
setenv = PYTHONPATH = {toxinidir}/src
deps =
    -r requirements.txt
    .[spell_check]
commands =
    codespell --ignore-words=domain-specific-terms.txt src
    codespell --ignore-words=domain-specific-terms.txt README.md

[testenv:coverage]
changedir = unittests
deps =
    {[testenv:tests]deps}
    .[coverage]
setenv = PYTHONPATH = {toxinidir}/src
commands =
    coverage run -m pytest --basetemp={envtmpdir} {posargs}
    coverage html --omit .tox/*,unittests/*
    coverage report --fail-under 80 --omit .tox/*,unittests/*

[testenv:codegen]
deps =
    .[codegen]
    .[formatting]
setenv = PYTHONPATH = {toxinidir}/src
commands =
    datamodel-codegen --input openapi/v1/swagger.json --output src/decidalo_client/models/_autogenerated.py --input-file-type openapi --output-model-type pydantic_v2.BaseModel --target-python-version 3.11 --use-annotated --use-double-quotes --collapse-root-models --field-constraints --strict-nullable --use-standard-collections --enum-field-as-literal one
    black src/decidalo_client/models/_autogenerated.py
    isort src/decidalo_client/models/_autogenerated.py

[testenv:dev]
deps =
    {[testenv:tests]deps}
    {[testenv:linting]deps}
    {[testenv:type_check]deps}
    {[testenv:coverage]deps}
    {[testenv:spell_check]deps}
    .[formatting]
    .[dev]
    .[codegen]
commands =
    python -m pip install --upgrade pip
    pip install -r requirements.txt

[testenv:test_packaging]
skip_install = true
deps =
    .[packaging]
commands =
    python -m build
    twine check dist/*
```

**Step 2: Commit**

```bash
git add tox.ini
git commit -m "chore: configure tox.ini with codegen and linting ignores"
```

---

## Task 6: Generate Requirements and Run Codegen

**Files:**
- Modify: `requirements.txt`
- Create: `src/decidalo_client/models/_autogenerated.py`

**Step 1: Generate requirements.txt**

```bash
pip-compile pyproject.toml -o requirements.txt
```

**Step 2: Run codegen to generate models**

```bash
tox -e codegen
```

**Step 3: Verify generated models exist**

```bash
head -50 src/decidalo_client/models/_autogenerated.py
```
Expected: Python file with Pydantic model classes

**Step 4: Commit**

```bash
git add requirements.txt src/decidalo_client/models/_autogenerated.py
git commit -m "feat: generate Pydantic models from OpenAPI spec"
```

---

## Task 7: Write the DecidaloClient Class

**Files:**
- Create: `src/decidalo_client/client.py`
- Create: `src/decidalo_client/exceptions.py`

**Step 1: Create `src/decidalo_client/exceptions.py`**

```python
"""
Custom exceptions for the decidalo client.
"""


class DecidaloClientError(Exception):
    """Base exception for decidalo client errors."""


class DecidaloAPIError(DecidaloClientError):
    """Raised when the API returns an error response."""

    def __init__(self, status_code: int, message: str) -> None:
        self.status_code = status_code
        self.message = message
        super().__init__(f"API error {status_code}: {message}")


class DecidaloAuthenticationError(DecidaloAPIError):
    """Raised when authentication fails (401/403)."""
```

**Step 2: Create `src/decidalo_client/client.py`**

```python
"""
Async HTTP client for the decidalo V3 Import API.
"""

from typing import Any

import aiohttp
from pydantic import TypeAdapter

from decidalo_client.exceptions import DecidaloAPIError, DecidaloAuthenticationError
from decidalo_client.models import (
    AbsenceImportResult,
    AbsenceOutputResult,
    BookingBatchInput,
    BookingBatchStatus,
    BookingItemOutput,
    CompanyCompleteOutput,
    ImportAbsencesCommand,
    ImportBatchStatus,
    ImportCompanyCommand,
    ImportProjectReferenceCommand,
    ImportRoleCommand,
    ImportUserCommand,
    ProjectReferenceOutput,
    ResourceRequestInput,
    ResourceRequestOutput,
    TeamBatchInput,
    TeamBatchStatus,
    TeamOverview,
    UserBatchInput,
    UserOverview,
    UserWorkingProfileInput,
    WorkingProfileResult,
)


class DecidaloClient:
    """
    Async client for the decidalo V3 Import API.

    Usage:
        async with DecidaloClient(api_key="your-api-key") as client:
            users = await client.get_users()
    """

    DEFAULT_BASE_URL = "https://import.decidalo.dev"

    def __init__(
        self,
        api_key: str,
        base_url: str = DEFAULT_BASE_URL,
        session: aiohttp.ClientSession | None = None,
    ) -> None:
        """
        Initialize the decidalo client.

        Args:
            api_key: The API key for authentication (X-Api-Key header).
            base_url: The base URL of the API. Defaults to production.
            session: Optional aiohttp session. If not provided, one will be created.
        """
        self._api_key = api_key
        self._base_url = base_url.rstrip("/")
        self._session = session
        self._owns_session = session is None

    async def __aenter__(self) -> "DecidaloClient":
        """Enter async context manager."""
        if self._session is None:
            self._session = aiohttp.ClientSession()
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: Any,
    ) -> None:
        """Exit async context manager."""
        if self._owns_session and self._session is not None:
            await self._session.close()

    def _get_headers(self) -> dict[str, str]:
        """Get default headers for API requests."""
        return {
            "X-Api-Key": self._api_key,
            "Content-Type": "application/json",
            "Accept": "application/json",
        }

    async def _request(
        self,
        method: str,
        path: str,
        params: dict[str, Any] | None = None,
        json_data: Any = None,
    ) -> aiohttp.ClientResponse:
        """Make an HTTP request to the API."""
        if self._session is None:
            raise DecidaloClientError("Client not initialized. Use 'async with' context manager.")

        url = f"{self._base_url}{path}"
        response = await self._session.request(
            method=method,
            url=url,
            headers=self._get_headers(),
            params=params,
            json=json_data,
        )

        if response.status in (401, 403):
            text = await response.text()
            raise DecidaloAuthenticationError(response.status, text)

        if response.status >= 400:
            text = await response.text()
            raise DecidaloAPIError(response.status, text)

        return response

    # ==================== Users ====================

    async def get_users(
        self,
        email: str | None = None,
        user_id: str | None = None,
    ) -> list[UserOverview]:
        """
        Retrieve users, optionally filtered by email or user ID.

        Args:
            email: Filter by email address.
            user_id: Filter by user ID.

        Returns:
            List of user overviews.
        """
        params: dict[str, str] = {}
        if email:
            params["email"] = email
        if user_id:
            params["userId"] = user_id

        response = await self._request("GET", "/importapi/User", params=params or None)
        data = await response.json()
        return TypeAdapter(list[UserOverview]).validate_python(data)

    async def import_users_async(
        self,
        users: UserBatchInput,
    ) -> ImportBatchStatus:
        """
        Import users asynchronously (batch).

        Args:
            users: Batch of users to import.

        Returns:
            Import batch status with batch ID for polling.
        """
        response = await self._request(
            "POST",
            "/importapi/User/ImportAsync",
            json_data=users.model_dump(mode="json", by_alias=True, exclude_none=True),
        )
        data = await response.json()
        return ImportBatchStatus.model_validate(data)

    async def get_user_import_status(self, batch_id: str) -> ImportBatchStatus:
        """
        Check the status of a user import batch.

        Args:
            batch_id: The batch ID from import_users_async.

        Returns:
            Import batch status.
        """
        response = await self._request("GET", "/importapi/User/ImportStatus", params={"batchId": batch_id})
        data = await response.json()
        return ImportBatchStatus.model_validate(data)

    # ==================== Teams ====================

    async def get_teams(self) -> list[TeamOverview]:
        """
        Retrieve all teams.

        Returns:
            List of team overviews.
        """
        response = await self._request("GET", "/importapi/Team")
        data = await response.json()
        return TypeAdapter(list[TeamOverview]).validate_python(data)

    async def import_teams_async(self, teams: TeamBatchInput) -> TeamBatchStatus:
        """
        Import teams asynchronously (batch).

        Args:
            teams: Batch of teams to import.

        Returns:
            Team batch status with batch ID for polling.
        """
        response = await self._request(
            "POST",
            "/importapi/Team/ImportAsync",
            json_data=teams.model_dump(mode="json", by_alias=True, exclude_none=True),
        )
        data = await response.json()
        return TeamBatchStatus.model_validate(data)

    async def import_teams_sync(self, teams: TeamBatchInput) -> TeamBatchStatus:
        """
        Import teams synchronously (batch).

        Args:
            teams: Batch of teams to import.

        Returns:
            Team batch status.
        """
        response = await self._request(
            "POST",
            "/importapi/Team/ImportSync",
            json_data=teams.model_dump(mode="json", by_alias=True, exclude_none=True),
        )
        data = await response.json()
        return TeamBatchStatus.model_validate(data)

    async def get_team_import_status(self, batch_id: str) -> TeamBatchStatus:
        """
        Check the status of a team import batch.

        Args:
            batch_id: The batch ID from import_teams_async.

        Returns:
            Team batch status.
        """
        response = await self._request("GET", "/importapi/Team/ImportStatus", params={"batchId": batch_id})
        data = await response.json()
        return TeamBatchStatus.model_validate(data)

    # ==================== Companies ====================

    async def get_companies(self) -> list[CompanyCompleteOutput]:
        """
        Retrieve all companies.

        Returns:
            List of companies.
        """
        response = await self._request("GET", "/importapi/Company")
        data = await response.json()
        return TypeAdapter(list[CompanyCompleteOutput]).validate_python(data)

    async def import_company(self, company: ImportCompanyCommand) -> CompanyCompleteOutput:
        """
        Create or update a company.

        Args:
            company: Company data to import.

        Returns:
            The created/updated company.
        """
        response = await self._request(
            "POST",
            "/importapi/Company/Import",
            json_data=company.model_dump(mode="json", by_alias=True, exclude_none=True),
        )
        data = await response.json()
        return CompanyCompleteOutput.model_validate(data)

    # ==================== Projects ====================

    async def get_project(
        self,
        project_id: str | None = None,
        project_code: str | None = None,
    ) -> ProjectReferenceOutput:
        """
        Retrieve a project by ID or code.

        Args:
            project_id: The project ID.
            project_code: The project code.

        Returns:
            Project details.
        """
        params: dict[str, str] = {}
        if project_id:
            params["projectId"] = project_id
        if project_code:
            params["projectCode"] = project_code

        response = await self._request("GET", "/importapi/Project", params=params)
        data = await response.json()
        return ProjectReferenceOutput.model_validate(data)

    async def get_all_projects(
        self,
        page: int = 1,
        page_size: int = 100,
    ) -> list[ProjectReferenceOutput]:
        """
        Retrieve all projects with pagination.

        Args:
            page: Page number (1-indexed).
            page_size: Number of projects per page.

        Returns:
            List of projects.
        """
        params = {"page": str(page), "pageSize": str(page_size)}
        response = await self._request("GET", "/importapi/Project/AllProjects", params=params)
        data = await response.json()
        return TypeAdapter(list[ProjectReferenceOutput]).validate_python(data)

    async def import_project(self, project: ImportProjectReferenceCommand) -> ProjectReferenceOutput:
        """
        Create or update a project.

        Args:
            project: Project data to import.

        Returns:
            The created/updated project.
        """
        response = await self._request(
            "POST",
            "/importapi/Project/Import",
            json_data=project.model_dump(mode="json", by_alias=True, exclude_none=True),
        )
        data = await response.json()
        return ProjectReferenceOutput.model_validate(data)

    async def project_exists(
        self,
        project_id: str | None = None,
        project_code: str | None = None,
    ) -> bool:
        """
        Check if a project exists.

        Args:
            project_id: The project ID.
            project_code: The project code.

        Returns:
            True if project exists, False otherwise.
        """
        params: dict[str, str] = {}
        if project_id:
            params["projectId"] = project_id
        if project_code:
            params["projectCode"] = project_code

        try:
            await self._request("HEAD", "/importapi/Project", params=params)
            return True
        except DecidaloAPIError as e:
            if e.status_code == 404:
                return False
            raise

    # ==================== Bookings ====================

    async def get_bookings(
        self,
        user_id: str | None = None,
        project_id: str | None = None,
        start_date: str | None = None,
        end_date: str | None = None,
    ) -> list[BookingItemOutput]:
        """
        Retrieve bookings with optional filters.

        Args:
            user_id: Filter by user ID.
            project_id: Filter by project ID.
            start_date: Filter by start date (ISO format).
            end_date: Filter by end date (ISO format).

        Returns:
            List of bookings.
        """
        params: dict[str, str] = {}
        if user_id:
            params["userId"] = user_id
        if project_id:
            params["projectId"] = project_id
        if start_date:
            params["startDate"] = start_date
        if end_date:
            params["endDate"] = end_date

        response = await self._request("GET", "/importapi/Booking", params=params or None)
        data = await response.json()
        return TypeAdapter(list[BookingItemOutput]).validate_python(data)

    async def get_bookings_by_project(self, project_id: str) -> list[BookingItemOutput]:
        """
        Retrieve all bookings for a specific project.

        Args:
            project_id: The project ID.

        Returns:
            List of bookings.
        """
        response = await self._request("GET", "/importapi/Booking/ByProject", params={"projectId": project_id})
        data = await response.json()
        return TypeAdapter(list[BookingItemOutput]).validate_python(data)

    async def import_bookings_async(self, bookings: BookingBatchInput) -> BookingBatchStatus:
        """
        Import bookings asynchronously (batch).

        Args:
            bookings: Batch of bookings to import.

        Returns:
            Booking batch status with batch ID for polling.
        """
        response = await self._request(
            "POST",
            "/importapi/Booking/ImportAsync",
            json_data=bookings.model_dump(mode="json", by_alias=True, exclude_none=True),
        )
        data = await response.json()
        return BookingBatchStatus.model_validate(data)

    # ==================== Absences ====================

    async def get_absences(
        self,
        start_date: str | None = None,
        end_date: str | None = None,
    ) -> AbsenceOutputResult:
        """
        Retrieve absences with optional date filter.

        Args:
            start_date: Filter by start date (ISO format).
            end_date: Filter by end date (ISO format).

        Returns:
            Absence output result containing list of absences.
        """
        params: dict[str, str] = {}
        if start_date:
            params["startDate"] = start_date
        if end_date:
            params["endDate"] = end_date

        response = await self._request("GET", "/importapi/Absence", params=params or None)
        data = await response.json()
        return AbsenceOutputResult.model_validate(data)

    async def import_absences(self, absences: ImportAbsencesCommand) -> list[AbsenceImportResult]:
        """
        Import absences (create, update, or delete).

        Args:
            absences: Absences to import.

        Returns:
            List of import results.
        """
        response = await self._request(
            "POST",
            "/importapi/Absence/Import",
            json_data=absences.model_dump(mode="json", by_alias=True, exclude_none=True),
        )
        data = await response.json()
        return TypeAdapter(list[AbsenceImportResult]).validate_python(data)

    # ==================== Resource Requests ====================

    async def get_resource_request(self, request_id: str) -> ResourceRequestOutput:
        """
        Retrieve a specific resource request.

        Args:
            request_id: The resource request ID.

        Returns:
            Resource request details.
        """
        response = await self._request("GET", f"/importapi/ResourceRequest/{request_id}")
        data = await response.json()
        return ResourceRequestOutput.model_validate(data)

    async def import_resource_request(self, request: ResourceRequestInput) -> ResourceRequestOutput:
        """
        Create, update, or delete a resource request.

        Args:
            request: Resource request data.

        Returns:
            The resource request result.
        """
        response = await self._request(
            "POST",
            "/importapi/ResourceRequest",
            json_data=request.model_dump(mode="json", by_alias=True, exclude_none=True),
        )
        data = await response.json()
        return ResourceRequestOutput.model_validate(data)

    # ==================== Roles ====================

    async def import_role(self, role: ImportRoleCommand) -> None:
        """
        Create or update a role with skills and certificates.

        Args:
            role: Role data to import.
        """
        await self._request(
            "POST",
            "/importapi/Role",
            json_data=role.model_dump(mode="json", by_alias=True, exclude_none=True),
        )

    # ==================== Working Time Patterns ====================

    async def get_working_time_patterns(
        self,
        user_id: str | None = None,
    ) -> list[WorkingProfileResult]:
        """
        Retrieve working time patterns, optionally filtered by user.

        Args:
            user_id: Filter by user ID.

        Returns:
            List of working time patterns.
        """
        params: dict[str, str] = {}
        if user_id:
            params["userId"] = user_id

        response = await self._request("GET", "/importapi/WorkingTimePattern", params=params or None)
        data = await response.json()
        return TypeAdapter(list[WorkingProfileResult]).validate_python(data)

    async def import_working_time_pattern(
        self,
        pattern: UserWorkingProfileInput,
    ) -> list[WorkingProfileResult]:
        """
        Create or update working time patterns for a user.

        Args:
            pattern: Working time pattern data.

        Returns:
            List of created/updated patterns.
        """
        response = await self._request(
            "POST",
            "/importapi/WorkingTimePattern/Import",
            json_data=pattern.model_dump(mode="json", by_alias=True, exclude_none=True),
        )
        data = await response.json()
        return TypeAdapter(list[WorkingProfileResult]).validate_python(data)
```

**Step 3: Update `src/decidalo_client/__init__.py` to export exceptions**

```python
"""
decidalo_client - An async Python client for the decidalo V3 Import API.
"""

from decidalo_client.client import DecidaloClient
from decidalo_client.exceptions import DecidaloAPIError, DecidaloAuthenticationError, DecidaloClientError

__all__ = [
    "DecidaloClient",
    "DecidaloAPIError",
    "DecidaloAuthenticationError",
    "DecidaloClientError",
]
```

**Step 4: Commit**

```bash
git add src/decidalo_client/
git commit -m "feat: implement DecidaloClient with all API endpoints"
```

---

## Task 8: Write Comprehensive Tests

**Files:**
- Create: `unittests/test_client.py`
- Create: `unittests/conftest.py`

**Step 1: Create `unittests/conftest.py`**

```python
"""
Pytest configuration and fixtures for decidalo_client tests.
"""

import pytest
from aioresponses import aioresponses


@pytest.fixture
def mock_aiohttp() -> aioresponses:  # type: ignore[misc]
    """Fixture providing aioresponses mock."""
    with aioresponses() as m:
        yield m
```

**Step 2: Create `unittests/test_client.py`**

```python
"""
Tests for the DecidaloClient.
"""

import pytest
from aioresponses import aioresponses

from decidalo_client import DecidaloClient, DecidaloAPIError, DecidaloAuthenticationError


BASE_URL = "https://import.decidalo.dev"
API_KEY = "test-api-key"


class TestDecidaloClient:
    """Tests for DecidaloClient."""

    @pytest.fixture
    def mock_aiohttp(self) -> aioresponses:  # type: ignore[misc]
        """Fixture providing aioresponses mock."""
        with aioresponses() as m:
            yield m

    async def test_get_users_empty(self, mock_aiohttp: aioresponses) -> None:
        """Test getting users returns empty list."""
        mock_aiohttp.get(f"{BASE_URL}/importapi/User", payload=[])

        async with DecidaloClient(api_key=API_KEY) as client:
            users = await client.get_users()
            assert users == []

    async def test_get_users_with_data(self, mock_aiohttp: aioresponses) -> None:
        """Test getting users returns user data."""
        mock_aiohttp.get(
            f"{BASE_URL}/importapi/User",
            payload=[
                {
                    "id": "user-123",
                    "email": "test@example.com",
                    "firstName": "Test",
                    "lastName": "User",
                }
            ],
        )

        async with DecidaloClient(api_key=API_KEY) as client:
            users = await client.get_users()
            assert len(users) == 1
            assert users[0].email == "test@example.com"

    async def test_get_users_with_email_filter(self, mock_aiohttp: aioresponses) -> None:
        """Test getting users with email filter."""
        mock_aiohttp.get(
            f"{BASE_URL}/importapi/User?email=test%40example.com",
            payload=[{"id": "user-123", "email": "test@example.com"}],
        )

        async with DecidaloClient(api_key=API_KEY) as client:
            users = await client.get_users(email="test@example.com")
            assert len(users) == 1

    async def test_get_teams(self, mock_aiohttp: aioresponses) -> None:
        """Test getting teams."""
        mock_aiohttp.get(
            f"{BASE_URL}/importapi/Team",
            payload=[{"id": "team-1", "name": "Engineering"}],
        )

        async with DecidaloClient(api_key=API_KEY) as client:
            teams = await client.get_teams()
            assert len(teams) == 1
            assert teams[0].name == "Engineering"

    async def test_get_companies(self, mock_aiohttp: aioresponses) -> None:
        """Test getting companies."""
        mock_aiohttp.get(
            f"{BASE_URL}/importapi/Company",
            payload=[{"id": "company-1", "name": "Acme Corp"}],
        )

        async with DecidaloClient(api_key=API_KEY) as client:
            companies = await client.get_companies()
            assert len(companies) == 1
            assert companies[0].name == "Acme Corp"

    async def test_get_project(self, mock_aiohttp: aioresponses) -> None:
        """Test getting a project by ID."""
        mock_aiohttp.get(
            f"{BASE_URL}/importapi/Project?projectId=proj-123",
            payload={"id": "proj-123", "name": "Test Project"},
        )

        async with DecidaloClient(api_key=API_KEY) as client:
            project = await client.get_project(project_id="proj-123")
            assert project.name == "Test Project"

    async def test_get_all_projects(self, mock_aiohttp: aioresponses) -> None:
        """Test getting all projects with pagination."""
        mock_aiohttp.get(
            f"{BASE_URL}/importapi/Project/AllProjects?page=1&pageSize=100",
            payload=[{"id": "proj-1", "name": "Project 1"}],
        )

        async with DecidaloClient(api_key=API_KEY) as client:
            projects = await client.get_all_projects()
            assert len(projects) == 1

    async def test_project_exists_true(self, mock_aiohttp: aioresponses) -> None:
        """Test project_exists returns True when project exists."""
        mock_aiohttp.head(f"{BASE_URL}/importapi/Project?projectId=proj-123", status=200)

        async with DecidaloClient(api_key=API_KEY) as client:
            exists = await client.project_exists(project_id="proj-123")
            assert exists is True

    async def test_project_exists_false(self, mock_aiohttp: aioresponses) -> None:
        """Test project_exists returns False when project not found."""
        mock_aiohttp.head(f"{BASE_URL}/importapi/Project?projectId=nonexistent", status=404)

        async with DecidaloClient(api_key=API_KEY) as client:
            exists = await client.project_exists(project_id="nonexistent")
            assert exists is False

    async def test_get_bookings(self, mock_aiohttp: aioresponses) -> None:
        """Test getting bookings."""
        mock_aiohttp.get(
            f"{BASE_URL}/importapi/Booking",
            payload=[{"id": "booking-1", "projectId": "proj-1"}],
        )

        async with DecidaloClient(api_key=API_KEY) as client:
            bookings = await client.get_bookings()
            assert len(bookings) == 1

    async def test_get_absences(self, mock_aiohttp: aioresponses) -> None:
        """Test getting absences."""
        mock_aiohttp.get(
            f"{BASE_URL}/importapi/Absence",
            payload={"absences": []},
        )

        async with DecidaloClient(api_key=API_KEY) as client:
            result = await client.get_absences()
            assert result.absences == []

    async def test_get_resource_request(self, mock_aiohttp: aioresponses) -> None:
        """Test getting a resource request."""
        mock_aiohttp.get(
            f"{BASE_URL}/importapi/ResourceRequest/req-123",
            payload={"id": "req-123", "title": "Need Developer"},
        )

        async with DecidaloClient(api_key=API_KEY) as client:
            request = await client.get_resource_request("req-123")
            assert request.title == "Need Developer"

    async def test_get_working_time_patterns(self, mock_aiohttp: aioresponses) -> None:
        """Test getting working time patterns."""
        mock_aiohttp.get(
            f"{BASE_URL}/importapi/WorkingTimePattern",
            payload=[],
        )

        async with DecidaloClient(api_key=API_KEY) as client:
            patterns = await client.get_working_time_patterns()
            assert patterns == []

    async def test_authentication_error_401(self, mock_aiohttp: aioresponses) -> None:
        """Test authentication error handling for 401."""
        mock_aiohttp.get(f"{BASE_URL}/importapi/User", status=401, body="Unauthorized")

        async with DecidaloClient(api_key=API_KEY) as client:
            with pytest.raises(DecidaloAuthenticationError) as exc_info:
                await client.get_users()
            assert exc_info.value.status_code == 401

    async def test_authentication_error_403(self, mock_aiohttp: aioresponses) -> None:
        """Test authentication error handling for 403."""
        mock_aiohttp.get(f"{BASE_URL}/importapi/User", status=403, body="Forbidden")

        async with DecidaloClient(api_key=API_KEY) as client:
            with pytest.raises(DecidaloAuthenticationError) as exc_info:
                await client.get_users()
            assert exc_info.value.status_code == 403

    async def test_api_error_500(self, mock_aiohttp: aioresponses) -> None:
        """Test API error handling for 500."""
        mock_aiohttp.get(f"{BASE_URL}/importapi/User", status=500, body="Internal Server Error")

        async with DecidaloClient(api_key=API_KEY) as client:
            with pytest.raises(DecidaloAPIError) as exc_info:
                await client.get_users()
            assert exc_info.value.status_code == 500

    async def test_custom_base_url(self, mock_aiohttp: aioresponses) -> None:
        """Test using custom base URL."""
        custom_url = "https://custom.decidalo.dev"
        mock_aiohttp.get(f"{custom_url}/importapi/User", payload=[])

        async with DecidaloClient(api_key=API_KEY, base_url=custom_url) as client:
            users = await client.get_users()
            assert users == []

    async def test_headers_include_api_key(self, mock_aiohttp: aioresponses) -> None:
        """Test that requests include the API key header."""
        mock_aiohttp.get(f"{BASE_URL}/importapi/User", payload=[])

        async with DecidaloClient(api_key="my-secret-key") as client:
            await client.get_users()

        # Verify the request was made (aioresponses validates headers match)
        assert mock_aiohttp.requests[("GET", f"{BASE_URL}/importapi/User")]
```

**Step 3: Update `unittests/__init__.py`**

```python
"""
Unit tests for decidalo_client.
"""
```

**Step 4: Commit**

```bash
git add unittests/
git commit -m "test: add comprehensive tests for DecidaloClient"
```

---

## Task 9: Write README

**Files:**
- Modify: `README.md`

**Step 1: Replace README.md content**

```markdown
# decidalo_client

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
![Python Versions (officially) supported](https://img.shields.io/pypi/pyversions/decidalo_client.svg)
![Pypi status badge](https://img.shields.io/pypi/v/decidalo_client)

An async Python client for the [decidalo V3 Import API](https://import.decidalo.dev/index.html).

## Installation

```bash
pip install decidalo_client
```

## Quick Start

```python
import asyncio
from decidalo_client import DecidaloClient

async def main():
    async with DecidaloClient(api_key="your-api-key") as client:
        # Get all users
        users = await client.get_users()
        for user in users:
            print(f"{user.first_name} {user.last_name} ({user.email})")

        # Get all projects
        projects = await client.get_all_projects()
        for project in projects:
            print(f"{project.name}")

asyncio.run(main())
```

## Features

- Fully async using aiohttp
- Type-safe with Pydantic models
- All API endpoints covered:
  - Users (get, import)
  - Teams (get, import sync/async)
  - Companies (get, import)
  - Projects (get, import, exists check)
  - Bookings (get, import)
  - Absences (get, import)
  - Resource Requests (get, import)
  - Roles (import)
  - Working Time Patterns (get, import)

## API Reference

### Client Initialization

```python
from decidalo_client import DecidaloClient

# Using context manager (recommended)
async with DecidaloClient(api_key="your-api-key") as client:
    ...

# With custom base URL (e.g., for sandbox)
async with DecidaloClient(
    api_key="your-api-key",
    base_url="https://import.decidalo.dev"
) as client:
    ...
```

### Error Handling

```python
from decidalo_client import DecidaloClient, DecidaloAPIError, DecidaloAuthenticationError

async with DecidaloClient(api_key="your-api-key") as client:
    try:
        users = await client.get_users()
    except DecidaloAuthenticationError as e:
        print(f"Auth failed: {e.status_code}")
    except DecidaloAPIError as e:
        print(f"API error: {e.status_code} - {e.message}")
```

## Development Setup

For detailed development setup instructions, see the [Python Template Repository](https://github.com/Hochfrequenz/python_template_repository).

### Quick Start

```bash
# Clone the repository
git clone https://github.com/Hochfrequenz/decidalo_client.py.git
cd decidalo_client.py

# Create dev environment
tox -e dev

# Regenerate models from OpenAPI spec
tox -e codegen

# Run tests
tox -e tests

# Run all checks
tox
```

## License

MIT License - see [LICENSE](LICENSE) for details.
```

**Step 2: Commit**

```bash
git add README.md
git commit -m "docs: write README with usage examples and badges"
```

---

## Task 10: Enable Publishing Workflow

**Files:**
- Modify: `.github/workflows/python-publish.yml`

**Step 1: Uncomment the publishing workflow**

Uncomment lines 8-66 in `.github/workflows/python-publish.yml` (remove the `#` prefix from each line).

**Step 2: Commit**

```bash
git add .github/workflows/python-publish.yml
git commit -m "ci: enable PyPI publishing workflow"
```

---

## Task 11: Create Playground Script

**Files:**
- Create: `playground.py`

**Step 1: Create playground.py**

```python
#!/usr/bin/env python
"""
Playground script for testing the decidalo client against the real API.

Usage:
    export DECIDALO_API_KEY="your-api-key"
    python playground.py

Or pass the API key directly:
    python playground.py --api-key "your-api-key"
"""

import argparse
import asyncio
import os

from decidalo_client import DecidaloClient, DecidaloAPIError, DecidaloAuthenticationError


async def main(api_key: str, base_url: str) -> None:
    """Run playground examples."""
    print(f"Connecting to: {base_url}")
    print("-" * 50)

    async with DecidaloClient(api_key=api_key, base_url=base_url) as client:
        # Example 1: Get users
        print("\n[1] Getting users...")
        try:
            users = await client.get_users()
            print(f"    Found {len(users)} users")
            for user in users[:5]:  # Show first 5
                print(f"    - {user.email}")
            if len(users) > 5:
                print(f"    ... and {len(users) - 5} more")
        except DecidaloAPIError as e:
            print(f"    Error: {e}")

        # Example 2: Get teams
        print("\n[2] Getting teams...")
        try:
            teams = await client.get_teams()
            print(f"    Found {len(teams)} teams")
            for team in teams[:5]:
                print(f"    - {team.name}")
        except DecidaloAPIError as e:
            print(f"    Error: {e}")

        # Example 3: Get companies
        print("\n[3] Getting companies...")
        try:
            companies = await client.get_companies()
            print(f"    Found {len(companies)} companies")
            for company in companies[:5]:
                print(f"    - {company.name}")
        except DecidaloAPIError as e:
            print(f"    Error: {e}")

        # Example 4: Get projects
        print("\n[4] Getting projects...")
        try:
            projects = await client.get_all_projects(page=1, page_size=10)
            print(f"    Found {len(projects)} projects (page 1)")
            for project in projects[:5]:
                print(f"    - {project.name}")
        except DecidaloAPIError as e:
            print(f"    Error: {e}")

        # Example 5: Get bookings
        print("\n[5] Getting bookings...")
        try:
            bookings = await client.get_bookings()
            print(f"    Found {len(bookings)} bookings")
        except DecidaloAPIError as e:
            print(f"    Error: {e}")

        # Example 6: Get absences
        print("\n[6] Getting absences...")
        try:
            absences = await client.get_absences()
            absence_list = absences.absences or []
            print(f"    Found {len(absence_list)} absences")
        except DecidaloAPIError as e:
            print(f"    Error: {e}")

        # Example 7: Get working time patterns
        print("\n[7] Getting working time patterns...")
        try:
            patterns = await client.get_working_time_patterns()
            print(f"    Found {len(patterns)} patterns")
        except DecidaloAPIError as e:
            print(f"    Error: {e}")

    print("\n" + "-" * 50)
    print("Playground complete!")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Test the decidalo client against the real API")
    parser.add_argument(
        "--api-key",
        default=os.environ.get("DECIDALO_API_KEY"),
        help="API key (or set DECIDALO_API_KEY env var)",
    )
    parser.add_argument(
        "--base-url",
        default="https://import.decidalo.dev",
        help="Base URL of the API",
    )

    args = parser.parse_args()

    if not args.api_key:
        print("Error: API key required. Use --api-key or set DECIDALO_API_KEY env var")
        exit(1)

    try:
        asyncio.run(main(args.api_key, args.base_url))
    except DecidaloAuthenticationError as e:
        print(f"Authentication failed: {e}")
        exit(1)
    except KeyboardInterrupt:
        print("\nInterrupted")
```

**Step 2: Add playground.py to .gitignore exception or commit it**

```bash
git add playground.py
git commit -m "feat: add playground script for manual API testing"
```

---

## Task 12: Update Domain-Specific Terms

**Files:**
- Modify: `domain-specific-terms.txt`

**Step 1: Update domain-specific-terms.txt**

Add decidalo-specific terms that codespell might flag:

```
decidalo
aiohttp
pydantic
importapi
```

**Step 2: Commit**

```bash
git add domain-specific-terms.txt
git commit -m "chore: add domain-specific terms for spell check"
```

---

## Task 13: Run All CI Checks and Fix Issues

**Step 1: Run formatting**

```bash
tox -e dev
black src/ unittests/ playground.py
isort src/ unittests/ playground.py
```

**Step 2: Run linting**

```bash
tox -e linting
```

Fix any issues reported.

**Step 3: Run type checking**

```bash
tox -e type_check
```

Fix any type errors.

**Step 4: Run spell check**

```bash
tox -e spell_check
```

Add any flagged terms to domain-specific-terms.txt.

**Step 5: Run tests**

```bash
tox -e tests
```

All tests must pass.

**Step 6: Run coverage**

```bash
tox -e coverage
```

Coverage must be >= 80%.

**Step 7: Commit any fixes**

```bash
git add -A
git commit -m "fix: resolve CI check issues"
```

---

## Task 14: Code Review

**Step 1: Use code-reviewer subagent**

Request code review from specialized subagent with expertise in:
- OpenAPI code generation
- aiohttp async patterns
- Pydantic v2 models
- Python async/await best practices

**Step 2: Address review feedback**

Fix any issues identified in the review.

**Step 3: Final commit**

```bash
git add -A
git commit -m "fix: address code review feedback"
```

---

## Completion Checklist

- [ ] Branch `initial-setup` created
- [ ] OpenAPI spec downloaded to `openapi/v1/swagger.json`
- [ ] Pre-commit removed
- [ ] Package restructured to `decidalo_client`
- [ ] pyproject.toml updated with dependencies
- [ ] tox.ini updated with codegen and linting ignores
- [ ] Pydantic models generated
- [ ] DecidaloClient implemented with all endpoints
- [ ] Tests written with mocked responses
- [ ] README written with badges
- [ ] Publishing workflow enabled
- [ ] playground.py created
- [ ] All CI checks pass
- [ ] Code review completed
