Metadata-Version: 2.4
Name: kardia
Version: 0.0.35
Summary: A package for the Kardia API
Home-page: https://bitbucket.org/disciplemakers/kardia-api-python
Author: TJ Renninger
Author-email: renningert@dm.org
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.6
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests
Requires-Dist: typing_extensions; python_version < "3.8"
Provides-Extra: test
Requires-Dist: responses; extra == "test"
Requires-Dist: toml; extra == "test"
Dynamic: license-file

# Kardia Python API

A typed Python client library for the [Kardia](https://bitbucket.org/disciplemakers/kardia-api-python) system (a Centrallix-based ERP/CRM used by DiscipleMakers). It wraps Kardia's REST interface with Python modules and objects, so you work with `kardia.gl.getFund(...)` and `Fund(...)` instead of hand-building URLs and JSON payloads.

## Quickstart

Install (currently published to TestPyPI only — see [Installation](#markdown-header-installation)):

```console
$ python -m pip install -i https://test.pypi.org/simple/ kardia
```

Connect with your Kardia server URL and credentials:

```python
from kardia import Kardia

kardia = Kardia(kardia_url="https://your-kardia-server.com/apps/kardia", user="myuser", pw="mypassword")
```

Each Kardia domain is a lazily-constructed attribute on `kardia` — `kardia.gl`, `kardia.donor`, `kardia.partner`, etc. (see [Modules](#markdown-header-modules) for the full list). Reading data returns a `KardiaResponse` — a [`requests.Response`](https://requests.readthedocs.io/en/latest/api/#requests.Response) subclass, so everything you'd normally do with a `Response` (`.status_code`, `.json()`, `.content`, ...) still works, plus a couple of convenience additions (`.data`, `.ok_or_raise()`, `.osml` — see [API Module](#markdown-header-api-module)):

```python
>>> res = kardia.gl.getFund("L1", "1000")
>>> res.status_code
200
>>> res.json()
{'a_fund': '1000', ...}
>>> res.data  # same as res.json(), but None instead of raising on an empty/non-JSON body
{'a_fund': '1000', ...}
```

Writing data follows a consistent pattern: build an `*Object` from `kardia.objects`, then pass it to the matching `create*`/`update*` method:

```python
from kardia.objects.gl_objects import Fund

fund = Fund("1000", "L1", desc="General Fund")
res = kardia.gl.createFund(fund)
res.status_code  # 201 on success
```

Object constructors use Python-friendly names (`desc`, `ledger_id`) but serialize to Kardia's single-letter-prefixed attribute names (`a_fund_desc`, `a_ledger_number`) when sent as the request body — see [How It Works](#markdown-header-how-it-works) for the full mapping rules (booleans, `Enum`s, `datetime`, and `Decimal` money amounts).

## Table of Contents
- [Kardia Python API](#markdown-header-kardia-python-api)
  - [Quickstart](#markdown-header-quickstart)
  - [Installation](#markdown-header-installation)
  - [How It Works](#markdown-header-how-it-works)
    - [The Kardia Client](#markdown-header-the-kardia-client)
    - [Objects](#markdown-header-objects)
    - [Modules and Endpoints](#markdown-header-modules-and-endpoints)
    - [Authentication](#markdown-header-authentication)
    - [Known Quirks](#markdown-header-known-quirks)
  - [Kardia Objects](#markdown-header-kardia-objects)
    - [CRM Config](#markdown-header-crm-config)
    - [Designation](#markdown-header-designation)
    - [Disbursement](#markdown-header-disbursement)
    - [Donor](#markdown-header-donor)
    - [Fund Manager](#markdown-header-fund-manager)
    - [Gift](#markdown-header-gift)
    - [GL](#markdown-header-gl)
    - [Partner](#markdown-header-partner)
    - [Report](#markdown-header-report)
  - [Modules](#markdown-header-modules)
    - [API Module](#markdown-header-api-module)
    - [Account Config](#markdown-header-account-config)
    - [App Info](#markdown-header-app-info)
    - [Config](#markdown-header-config)
    - [CRM](#markdown-header-crm)
    - [CRM Config](#markdown-header-crm-config-1)
    - [Designation](#markdown-header-designation-1)
    - [Disbursements](#markdown-header-disbursements)
    - [Donor](#markdown-header-donor-1)
    - [Files](#markdown-header-files)
    - [Fund Manager](#markdown-header-fund-manager-1)
    - [Gift](#markdown-header-gift-1)
    - [GL](#markdown-header-gl-1)
    - [Partner](#markdown-header-partner-1)
    - [Report](#markdown-header-report-1)
    - [System Config](#markdown-header-system-config)
  - [Custom Modules](#markdown-header-custom-modules)
  - [URI Params](#markdown-header-uri-params)
    - [Auto](#markdown-header-auto)
    - [Type Properties](#markdown-header-type-properties)
    - [Params Method](#markdown-header-params-method)
  - [Development and Testing](#markdown-header-development-and-testing)

## Installation
---
Published to TestPyPI (there is no PyPI release yet — `make test-publish`/`make publish` are the only publish targets):

```console
$ python -m pip install -i https://test.pypi.org/simple/ kardia
```

> **Renamed from `kardia_api`.** The distribution and top-level import were renamed to `kardia` (`pip install kardia`, `from kardia import Kardia`). Existing code that does `import kardia_api` keeps working unchanged — the old name is now a thin alias package pointing at the same classes — but new code should use `kardia`.

For local development against a clone of this repo, install editable with the test extras (`responses`, `toml`):

```console
$ python -m pip install -e ".[test]"
```

Requires Python >= 3.6; the only runtime dependency is `requests`. The package ships a `py.typed` marker, so type checkers (mypy, pyright) and IDEs pick up its type hints automatically.

## How It Works
---
### The Kardia Client
`kardia.Kardia(kardia_url, user, pw)` is the top-level client. Each Kardia domain (`crm`, `crm_config`, `designation`, `disb`, `donor`, `fundmanager`, `gift`, `gl`, `partner`, `report`, `app_info`, `files`, `config`, `acctconfig`) is exposed as a lazily-instantiated attribute: the module class is only constructed (and its warm-up request only fires) the first time you access it, and the same instance is reused after that.

You can also reach endpoints not covered by a built-in module by writing your own `APIModule` subclass and registering it with `kardia.register_module(...)` — see [Custom Modules](#markdown-header-custom-modules).

### Objects
Every request payload object subclasses `APIObject` (`kardia/objects/api_object.py`), which:
- Stamps `s_created_by` / `s_modified_by` to the literal string `"api"` (not your connecting `user` — there is no "current user" concept baked into objects) and `s_date_created` / `s_date_modified` to the current timestamp, on every object you construct.
- Provides `serialize()`, called internally by every `create*`/`update*` method to build the request body. It walks the object's attributes, keeps only those matching Centrallix's single-letter-prefix convention (`^\w_.*` — e.g. `p_partner_key`, `a_gl_ledger_number`, `s_created_by`), and converts as it goes:
  - `bool` → `1`/`0`
  - `Enum` → `.value`
  - `datetime` → `{"year", "month", "day", "hour", "minute", "second"}`
  - `Decimal` → `{"wholepart": ..., "fractionpart": ...}` (fraction out of 10000, i.e. 4 decimal digits; negative amounts borrow from the whole part so the fraction stays positive, e.g. `Decimal("-1.25")` → `{"wholepart": -2, "fractionpart": 7500}`)
- Enforces required fields via `required_attrs` + `checkValid()` (called at the start of `serialize()`): if an object subclass sets `self.required_attrs = ["a_ledger_number", ...]` and any of those are falsy when you call `create*`, you get a plain `Exception` before any request is sent.

### Modules and Endpoints
Every module subclasses `APIModule` (`kardia/modules/api_module.py`), which owns the `requests.Session`, HTTP Basic auth, and endpoint construction. Each module defines one or more `Enum` classes whose members are URL path segments, each carrying a `PARENT` pointer up to its parent `Enum` (or `None` at the root). `_getEndpoint`/`_postEndpoint`/`_patchEndpoint` walk that `PARENT` chain and zip each segment with a positional value you pass in.

For example, `donor.py` defines this chain:

```python
class Root(Enum):
    PARENT = None
    DONOR_ID = "/"

class Endpoints(Enum):
    PARENT = Root.DONOR_ID
    YEARS = "Years"
    ...

class YearEndpoints(Enum):
    PARENT = Endpoints.YEARS
    GIFTS = "Gifts"
```

and `Donor.getYearGift`:

```python
def getYearGift(self, partner_id: str, year_id: str, gift_number: int) -> Response:
    return self._getEndpoint(YearEndpoints.GIFTS, partner_id, year_id, gift_number)
```

Walking `YearEndpoints.GIFTS` → `Endpoints.YEARS` → `Root.DONOR_ID` root-first, and zipping with `(partner_id, year_id, gift_number)`, produces:

```
GET {kardia_url}/api/donor//{partner_id}/Years/{year_id}/Gifts/{gift_number}?cx__mode=rest&cx__res_format=attrs
```

(the double slash after `donor` is intentional — see [Known Quirks](#markdown-header-known-quirks)). Module methods call `self._getEndpoint(...)`/`self._postEndpoint(...)`/`self._patchEndpoint(...)` with the target `Enum` member and the values needed to fill the chain; they don't hand-build URL strings.

URI params (`mode`, `res_type`, `res_format`, `res_attrs`, `res_levels` — see [Centrallix REST Interface URI Parameters](https://github.com/LightSys/centrallix/blob/b9d04ed04bdd8b194790e74f2ee67f4c7bc3c500/centrallix-sysdoc/JSON-REST.md#rest-interface-uri-parameters)) are usually inferred automatically; see [URI Params](#markdown-header-uri-params) for the three ways to control them explicitly.

### Authentication
Every request is sent with HTTP Basic auth (`user`/`pw`) over a shared `requests.Session`. POST/PATCH requests additionally need an **akey** (a Centrallix app auth token) — it's fetched lazily via a side request the first time you call a `create*`/`update*` method on a module instance, then cached on that instance for its lifetime (it is not automatically refreshed if it becomes stale — construct a fresh module instance, or re-access `kardia.<module>`'s backing instance, if that happens). A `401` response triggers exactly one automatic retry of the same request.

### Known Quirks
A few behaviors are intentional (or at least stable/relied-upon) rather than bugs to "fix" reflexively if you're extending this library:

- **Triple-slash URLs.** `donor.py`, `gl.py`/`disbursements.py`, `sys_config.py`, `acctconfig.py`, and `fundmanager.py` each define a root `Enum` whose value is the literal string `"/"`, used to slot a `ledger_id`/`manager_id` in as the first path segment — this produces a literal triple slash in the real request URL (e.g. `kardia.gl.getAccounts("L1")` → `/gl///L1/Accounts/`).
- **CRM's double slash.** `crm.py` is unrelated and even more unusual: it never sets `self.module` and doesn't use the `Enum`/`_getEndpoint` pattern at all — it hand-builds `self.endpoint` through a chain of private helpers, producing its own double-slash pattern.
- **`s_created_by` is always `"api"`.** See [Objects](#markdown-header-objects) above — don't rely on it reflecting the connecting user.

Two verified source bugs in `crm_config.py`, documented here (not silently perpetuated as correct behavior) rather than fixed, since this is a docs pass:
- `getTrackCollaborator(track_name, step_name)` and `getTrackCollaborators(track_name)` actually query the **Steps** endpoint, not a Collaborators endpoint — likely a copy/paste mistake.
- `getDocumentTypes()` actually queries `DataItemTypes`, not `DocumentTypes` (`getDocumentType(doc_type_id)`, the singular form, is correct).

## Kardia Objects
---
### APIObject
Parent class for all Kardia objects — see [Objects](#markdown-header-objects) above for what it does.
### CRM Config
```python
from kardia.objects import crm_config_objects as cco
```
**Track**
```python
cco.Track(name: str, description: str, status: Statuses = Statuses.ACTIVE, t_id: int = None, color: str = None)
```
**Step**
```python
cco.Step(track_id: int, name: str, description: str, sequence: int = 1, s_id: int = None)
```
**Track Collaborator**
```python
cco.TrackCollaborator(track_id: int, partner_id: str, collab_type_id: int, comments=None)
```
**Step Collaborator** (subclasses `TrackCollaborator`, adds `step_id`; used by `createStepCollaborator`)
```python
cco.StepCollaborator(track_id: int, step_id: int, partner_id: str, collab_type_id: int, comments)
```
**Step Requirement** (used by `createSetRequirement`)
```python
cco.StepRequirement(track_id: int, step_id: int, req_id: int, name: str, whom: Whom, waivable: bool, sequence: int = 1, active: bool = True, due_days_from_step: int = None, due_days_from_req: int = None, due_days_from_req_id: int = None, req_doc_type_id: int = None)
```

### Designation
```python
from kardia.objects import designation_objects as do
```
**Funding Target**
```python
do.FundingTarget(ledger_id: str, fund_id: str, target_id: int, target_desc: str, review: str = None, amount: str = "0.00", interval: int = Intervals.MONTHLY, start_date: datetime = datetime.today(), end_date: datetime = None)
```
**Admin Fee**
```python
do.AdminFee(fund_id: str, ledger_id: str, fee_type: str, subtype: str = None, percentage: float = None)
```
**Receipting**
```python
do.Receipting(fund_id: str, ledger_id: str, receiptable: bool = True, disposition: str = None)
```
**Receipting Account**
```python
do.ReceiptingAccount(fund_id: str, ledger_id: str, account: str, non_tax_deductible: bool = False, default: bool = False, receipt_comment: str = None)
```

### Disbursement
```python
from kardia.objects import disb_objects as do
```
**Line Item**
```python
do.LineItem(ledger: str = None, period: str = None, batch: int = None, disb_id: int = None, line_item_no: int = None, effective_date: datetime = datetime.today(), cash_account: str = None, amount: str = "0.00", fund: str = None, account: str = None, payee: str = None, check_no: str = None, posted: bool = False, gl_posted: bool = False, voided: bool = False, approved_by: str = None, approved_date: datetime = None, paid_by: str = None, paid_date: datetime = None, reconciled: bool = False, comment: str = "")
```

### Donor
```python
from kardia.objects import donor_objects as do
```
**Settings**
```python
do.Settings(key: str, ledger: str, account_code: str = None, account_with_donor: str = None, allow_contributions: bool = True, location_id: int = None, contact_id: int = None, org_name_first: bool = True, receipts: str = None, is_daf: bool = False)
```

### Fund Manager
```python
from kardia.objects import fundmanager_objects as fo
```
**Settings**
```python
fo.Settings(ledger_id: str, fund: str, partner_key: str, start_date: datetime = None, end_date: datetime = None)
```

### Gift
```python
from kardia.objects import gift_objects as go
```
**Gift**
```python
go.Gift(ledger: str = None, batch: int = None, gift: int = None, period: str = None, amount: Decimal = Decimal("0.00"), gift_type: GiftTypes = GiftTypes.CHECK, foreign_amount: Decimal = None, foreign_currency: str = None, foreign_currency_exchange_rate: float = None, foreign_currency_date: datetime = None, posted: bool = False, posted_to_gl: bool = False, receipt_number: str = None, partner: str = None, ack_partner: str = None, pass_partner: str = None, receipt_sent: bool = False, ack_receipt_sent: bool = False, receipt_desired: ReceiptPreferences = ReceiptPreferences.IMMEDIATE, ack_receipt_desired: ReceiptPreferences = None, first_gift: bool = False, goods_provided: Decimal = Decimal("0.00"), received_date: datetime = None, postmark_date: datetime = None, receipt_sent_date: datetime = None, ack_receipt_sent_date: datetime = None, comment: str = None, item_objects = [])
```
**Gift Item**
```python
go.GiftItem(ledger: str = None, batch: int = None, gift: int = None, split: int = None, period: str = None, fund: str = None, account_code: str = None, amount: Decimal = Decimal("0.00"), foreign_amount: Decimal = None, foreign_currency: str = None, foreign_currency_exchange_rate: float = None, foreign_currency_date: datetime = None, document_id: str = None, account_hash: str = None, check_front_image: str = None, check_back_image: str = None, posted: bool = False, posted_to_gl: bool = False, admin_fee: float = None, admin_fee_subtype: str = None, calc_admin_fee: float = None, calc_admin_fee_type: str = None, calc_admin_fee_subtype: str = None, recip_partner: str = None, confidential: bool = False, non_tax_deductible: bool = False, motivational_code: str = None, intent_code: str = None, comment: str = None, eg_source_key: str = None, donor_partner: str = None, ack_partner: str = None, pass_partner: str = None, receipt_number: str = None, received_date: datetime = None, postmark_date: datetime = None, gift_type: GiftTypes = None)
```
**EG Gift Import** — represents one row of an external-giving-platform (e.g. online donation processor) import, consumed by `createEGGiftImport`. It has ~70 fields covering donor identity, address, payment/currency details, and fund/account mapping confidence scores; see `objects/gift_objects.py`'s `EGGiftImport` class for the full field list rather than duplicating it here. Construct it with keyword arguments, e.g.:
```python
go.EGGiftImport(ledger="L1", gift="uuid-1", designation="uuid-2", line_item=1, transaction="uuid-3",
                 donor="uuid-4", status="new", processor="stripe", donor_name="Jane Donor",
                 amount=Decimal("50.00"), interval="one_time", date=datetime.now(), designation_name="General Fund")
```

### GL
```python
from kardia.objects import gl_objects as glo
```
**Account**
```python
glo.Account(code: str, ledger_id: str, desc: str, parent_code: str = None, acc_type: AccountTypes = AccountTypes.ASSET, acc_class: str = "GEN", reporting_level: int = 1, banking_key: str = None, contra: bool = False, posting: bool = True, inverted: bool = False, intrafund_xfer: bool = False, interfund_xfer: bool = False, comment: str = None, legacy_code: str = None, category: str = None)
```
**Fund**
```python
glo.Fund(fund_id: str, ledger_id: str, parent_id: str = None, bal_fund_id: str = None, fund_class: str = None, reporting_level: int = 1, posting: bool = True, external: bool = False, balancing=True, restricted_type: RestrictedTypes = RestrictedTypes.NOT_RESTRICTED, desc: str = None, comments: str = None, legacy_code: str = None)
```
**Period**
```python
glo.Period(period_id: str, ledger_id: str, start_date: datetime, end_date: datetime, parent_id: str = None, status: PeriodStatuses = PeriodStatuses.NEVER_OPENED, summary: bool = False, first_opened: datetime = None, last_closed: datetime = None, archived: datetime = None, desc: str = "", comment: str = "")
```
**Year**
```python
glo.Year(period_id: str, ledger_id: str, start_date: datetime, end_date: datetime, desc: str = "", comment: str = "")
```
**Batch**
```python
glo.Batch(ledger_id: str, period_id: str, batch_id: int = None, desc: str = "", origin: str = "GL", date: datetime = None)
```
**Transaction**
```python
glo.Transaction(ledger_id: str = None, period_id: str = None, batch_id: int = None, journal_id: int = None, date: datetime = datetime.today(), fund: str = None, account_category: AccountCategories = AccountCategories.EXPENSES,account_code: str = None, amount: str = "0.00", isPosted: bool = True, comment: str = None)
```
**Analysis Attribute**
```python
glo.AnalysisAttribute(attr_code: str, ledger_id: str, desc: str, fund_enable: bool, account_enable: bool)
```
**Analysis Attribute Value**
```python
glo.AnalysisAttributeValue(attr_code: str, ledger_id: str, value: str, desc: str = None)
```
**Account Analysis Attribute**
```python
glo.AccountAnalysisAttribute(attr_code: str, ledger_id: str, account_code: str, value: str = None)
```
**Fund Analysis Attribute**
```python
glo.FundAnalysisAttribute(attr_code: str, ledger_id: str, fund: str, value: str = None)
```

### Partner
```python
from kardia.objects import partner_objects as po
```
**Partner**
```python
po.Partner(key: str, office: str, parent_key: str = None, p_class: str = "IND", status_code: StatusCodes = StatusCodes.ACTIVE, status_date: datetime = None, p_title: str = None, first_name: str = None, preferred_name: str = None, last_name: str = None, last_name_first: bool = False, localized_name: str = None, suffix: str = None, org_name: str = None, gender: str = None, language: str = None, acquisition: str = None, comments: str = None, record_status_code: RecordStatusCodes = RecordStatusCodes.ACTIVE, no_mail_reason: str = None, no_solicitations: bool = False, no_mail: bool = False, fund: str = None, best_contact: str = None, merged_with: str = None, legacy_key_1: str = None, legacy_key_2: str = None, legacy_key_3: str = None, staff_object = None, address_objects = None, contact_info_objects = None)
```
**Address**
```python
po.Address(key: str, location_id: int, revision_id: int, location_type: str = None, date_effective: datetime = None, date_good_until: datetime = None, purge_date: datetime = None, in_care_of: str = None, addr1: str = None, addr2: str = None, addr3: str = None, city: str = None, state_province: str = None, country: str = None, postal_code: str = None, postal_mode: str = None, bulk_postal_code: str = None, certified_date: datetime = None, postal_status: str = None, postal_barcode: str = None, record_status_code: str = RecordStatusCodes.ACTIVE, comments: str = None):
```
**Contact Info**
```python
po.ContactInfo(key: str, contact_id: int, contact_type: str, location_id: str = None, phone_country: str = None, phone_area_city: str = None, contact_data: str = None, record_status_code: str = RecordStatusCodes.ACTIVE, comments: str = None)
```
**Staff**
```python
po.Staff(key: str, is_staff: bool = None, kardia_login: str = None, kardiaweb_login: str = None, preferred_email: int = None, preferred_location: int = None)
```

### Report
```python
from kardia.objects import report_objects as ro
```
**Sched Report Status** — used by `Report.updateSchedReportStatus`
```python
ro.SchedReportStatus(sent_status: SchedStatusTypes, sent_error: str, sent_date: datetime, generated_report_path: str)
```
**Sched Report Batch Status** — used by `Report.updateSchedReportBatchStatus`
```python
ro.SchedReportBatchStatus(sent_status: SchedStatusTypes = None, sent_by: str = None)
```
`SchedStatusTypes` values: `NOT_SENT`, `SENT`, `TEMPORARY_ERROR`, `INVALID_EMAIL_ERROR`, `SKIPPED`, `FAILURE_OTHER_ERROR`.

## Modules
---
### API Module
Parent class for all Kardia API modules — see [Modules and Endpoints](#markdown-header-modules-and-endpoints) above.

Every module method returns a `KardiaResponse`, a thin `requests.Response` subclass with four additions:
- `res.data` — `res.json()`, but returns `None` instead of raising on an empty or non-JSON body.
- `res.ok_or_raise(message=None)` — raises `KardiaAPIError` (carrying `.response`/`.status_code`) if the response isn't a 2xx, otherwise returns `res` so it's chainable:
```python
res = kardia.gl.createFund(fund).ok_or_raise("Failed to create fund")
```
- `res.get(name, default=None)` — shortcut for `res.osml.get(name, default)` (`default` if the body isn't a JSON object).
- `res.osml` — a `KardiaOSML` parse of the response body (`None` if the body isn't a JSON object), understanding both of Centrallix's [OSML JSON shapes](https://github.com/LightSys/centrallix/blob/master/centrallix-sysdoc/JSON-REST.md#osml-json-data-format): plain Basic-format values, and Full-format (`cx__res_attrs=full`) `{"a","e","v","t","h"}` attribute metadata. Money/Datetime values are decoded to `Decimal`/`datetime` either way. `KardiaOSML` exposes:
  - `.attrs` (alias `.element`) — `dict[str, OSMLAttribute]`, each with `.value` (decoded), `.raw_value`, `.type`, `.error`, `.hints`.
  - `.get(name, default=None)` / `osml[name]` — the decoded value for an attribute, or the child `KardiaOSML` if `name` names a collection member instead (e.g. `osml.get("Gifts")`).
  - `.children` (alias `.collection`) — `dict[str, KardiaOSML]` for a Full-format collection's `@id`-keyed child elements.
  - `.to_dict()` — a flat `{name: value}` dict of the decoded attrs.
  - Iterating a `KardiaOSML` (`for member in osml`) yields its collection members (`.children`'s values) — e.g. `for gift in res.osml.get("Gifts"): ...`.
- Iterating a `KardiaResponse` directly (`for member in res`) is a shortcut for iterating `res.osml` — e.g. `for year in kardia.gl.getYears("DM"): print(year)`. Raises `TypeError` if the body isn't a JSON object (e.g. a binary file download via `kardia.files.getFile`) — use `.content`/`.raw` for those instead.

`res_type=both` responses (`{"cx__element": {...}, "cx__collection": {...}}`) are transparently unwrapped: the element's attrs and the collection's children both land in `.attrs`/`.children` (aka `.element`/`.collection`) on the same `KardiaOSML` instance.
```python
res = kardia.gl.getFund("L1", "1000", res_attrs="full")
res.osml.get("a_fund")  # decoded value, same as res.osml["a_fund"]
res.osml.attrs["a_fund"].type  # e.g. "string"
```

### Account Config
```python
>>> kardia.acctconfig
```
#### Methods
- getConfigNames(ledger_id: str)
- getConfigValue(ledger_id: str, config_name: str)

### App Info
```python
>>> kardia.app_info
```
#### Methods
- getAppInfo()

### Config
```python
>>> kardia.config
```
#### Methods
- getConfig()

### CRM
```python
>>> kardia.crm
```
`Missionary` and `Supporter` functionality lives here rather than in separate modules — the `missionary.py`/`partnersearch.py`/`supporter.py` files that appear in `modules/` are empty stubs and aren't wired into `kardia`. There is no partner-search endpoint anywhere in this library.

#### Methods
- getTracks()
- getTrack(track: str)
- getTrackSteps(track: str)
- getTrackStep(track: str, step: str)
- getStepCollaborators(track: str, step: str)
- getStepCollaborator(track: str, step: str, track_id: int, step_id: int, collaborator_id: str)
- getTrackCollaborators(track: str)
- getTrackCollaborator(track: str, track_id: int, collaborator_id: str)
- getTagTypes()
- getTagType(tag_type_id: str)
- getContactHistTypes()
- getContactHistType(contact_history_type_id: str)
- getCollaboratorTypes()
- getCollaboratorType(collab_type_id: str)
- getTodoTypes()
- getTodoType(todo_type_id: str)
- getWorkflowTypes()
- getDataItemTypes()
- getDataItemType(di_type_id: str)
- getDocumentTypes()
- getDocumentType(doc_type_id: str)
- getCountries()
- getCountry(country: str)
- getTextExpansions()
- getTextExpansion(expansion_id)
- getPartners()
- getPartner(partner_id)
- getPartnerTags(partner_id: str)
- getPartnerTag(partner_id: str, tag: str)
- getPartnerDocuments(partner_id: str)
- getPartnerDocument(partner_id: str, document: str)
- getPartnerContactHistory(partner_id: str, contact_history_id: str = "")
- getPartnerContactAutorecord(partner_id: str, contact_history_id: str = "")
- getPartnerTracks(partner_id: str)
- getPartnerTrack(partner_id: str, track: str)
- getSupporters()
- getSupporter(supporter_id: str)
- getSupporterPrayers(supporter_id: str)
- getSupporterPrayer(supporter_id: str, prayer_id: str)
- getSupporterComments(supporter_id: str)
- getSupporterComment(supporter_id: str, comment_id: str)
- getSupporterMissionaries(supporter_id: str)
- getSupporterMissionary(supporter_id: str, missionary_id)
- getMissionaries()
- getMissionary(missionary_id)

`CRM` is read-only today (no `create*` methods), so there are no CRM request objects to document.

### CRM Config
```python
>>> kardia.crm_config
```
> **Known bugs** (see [Known Quirks](#markdown-header-known-quirks)): `getTrackCollaborator(s)` actually queries the Steps endpoint, and `getDocumentTypes()` actually queries `DataItemTypes` instead of `DocumentTypes`.

#### Methods
- getTrack(track_name: str)
- getTracks()
- getTrackStep(track_name: str, step_name: str)
- getTrackSteps(track_name: str)
- getStepCollaborator(track_name: str, step_name: str, collab_id: str)
- getStepCollaborators(track_name: str, step_name: str)
- getTrackCollaborator(track_name: str, step_name: str) — ⚠️ see known bugs above
- getTrackCollaborators(track_name: str) — ⚠️ see known bugs above
- getTagType(tag_id: int)
- getTagTypes()
- getContactHistType(ch_type_id: int)
- getContactHistTypes()
- getCollaboratorType(collab_type_id: int)
- getCollaboratorTypes()
- getTodoType(todo_type_id)
- getTodoTypes()
- getWorkflowType(wf_type_id)
- getWorkflowTypes()
- getDataItemType(di_type_id)
- getDataItemTypes()
- getDocumentType(doc_type_id)
- getDocumentTypes() — ⚠️ see known bugs above
- getCountry(country: str)
- getCountries()
- getTextExpansion(expansion: str)
- getTextExpansions()
- createTrack(track: Track)
- createTrackStep(track_name: str, step: Step)
- createStepCollaborator(track_name: str, step_name: str, collaborator: StepCollaborator)
- createSetRequirement(track_name: str, step_name: str, req: StepRequirement)

### Designation
```python
>>> kardia.designation
```
#### Methods
- getFundFundingTargets(ledger_id: str, fund_id: str)
- getFundingTargets()
- getFundingTarget(ledger_id: str, fund_id: str, target_id: str)
- getNextFundingTargetID(ledger_id: str, fund_id: str)
- createFundingTarget(funding_target: FundingTarget)
- getFundAdminFees(ledger_id: str, fund_id: str)
- getAdminFees()
- getAdminFee(ledger_id: str, fund_id: str)
- createAdminFee(admin_fee: AdminFee)
- getFundReceiptings(ledger_id: str, fund_id: str)
- getReceiptings()
- getReceipting(ledger_id: str, fund_id: str)
- createReceipting(receipting: Receipting)
- getFundReceiptingAccounts(ledger_id: str, fund_id: str)
- getReceiptingAccounts()
- getReceiptingAccount(ledger_id: str, fund_id: str, account_id: str)
- createReceiptingAccount(receipting_account: ReceiptingAccount)

### Disbursements
```python
>>> kardia.disb
```
`Disbursements` subclasses `GL`, so a `kardia.disb` instance also has every `GL` method in addition to the ones below.

#### Methods
- getLineItem(ledger: str, year: str, month: str, batch: int, check: int, line_item: int)
- getLineItems(ledger: str, year: str, month: str, batch: int)
- createLineItem(year: str, line_item: LineItem)
- createLineItems(year: str, line_items: List[LineItem])
- createBatchWithLineItems(ledger: str, year: str, month: str, line_items: List[LineItem], desc: str = "", date: datetime = None, post: bool = False, gl_post: bool = False) — raises if batch or line item creation fails

### Donor
```python
>>> kardia.donor
```
#### Methods
- getDonor(partner_id: str)
- getDonors()
- getDonorGifts(partner_id: str)
- getGiftReceipt(partner_id: str, gift_number: int)
- getYear(partner_id: str, year_id: str)
- getYears(partner_id: str)
- getYearGift(partner_id: str, year_id: str, gift_number: int)
- getYearGifts(partner_id: str, year_id: str)
- getYearGiftReceipt(partner_id: str, year_id: str, gift_number: int)
- getYearFund(partner_id: str, year_id: str, fund_id: str)
- getYearFunds(partner_id: str, year_id: str)
- getYearFundGift(partner_id: str, year_id: str, fund_id: str, gift_number: int)
- getYearFundGifts(partner_id: str, year_id: str, fund_id: str)
- getYearFundGiftReceipt(partner_id: str, year_id: str, fund_id: str, gift_number: int)
- getFund(partner_id: str, fund_id: str)
- getFunds(partner_id: str)
- getFundGift(partner_id: str, fund_id: str, gift_number: int)
- getFundGifts(partner_id: str, fund_id: str)
- getFundGiftReceipt(partner_id: str, fund_id: str, gift_number: int)
- getFundYear(partner_id: str, fund_id: str, year_id: str)
- getFundYears(partner_id: str, fund_id: str)
- getFundYearGift(partner_id: str, fund_id: str, year_id: str, gift_number: int)
- getFundYearGifts(partner_id: str, fund_id: str, year_id: str)
- getFundYearGiftReceipt(partner_id: str, fund_id: str, year_id: str, gift_number: int)
- getGivingInfo(partner_id: str, year_id: str)
- getDonorInfo(partner_id: str, year_id: str)
- getSettings()
- getSetting(donor_id: str, ledger_id: str)
- createSettings(settings: Settings)

`getDonorGift(partner_id, gift_number)` exists on the class but is unimplemented (`# TODO Figure out how to post a gift`, always returns `None`) — use `getYearGift`/`getFundGift` instead.

### Files
```python
>>> kardia.files
```
#### Methods
- getFile(filepath: str)

### Fund Manager
```python
>>> kardia.fundmanager
```
#### Methods
- getSettings(manager_id: str)
- getSetting(ledger_id: str, fund_id: str, manager_id: str)
- createSettings(settings: Settings)
- getFundManagers()
- getFundManager(manager_id: str)
- getFunds(manager_id: str)

### Gift
```python
>>> kardia.gift
```
#### Methods
- getLedger(ledger_id: str)
- getLedgers()
- getBatch(ledger_id: str, batch_id: int)
- getBatches(ledger_id: str)
- createBatch(batch: Batch, ledger_id: str)
- getGift(ledger_id: str, batch_id: int, gift_id: int)
- getGifts(ledger_id: str, batch_id: int)
- createGift(gift: Gift, ledger_id: str, batch_id: int)
- getGiftItem(ledger_id: str, batch_id: str, gift_id: int, item_id: int)
- getGiftItems(ledger_id: str, batch_id: int, gift_id: int)
- createGiftItem(item: GiftItem, ledger_id: str, batch_id: int, gift_id: int)
- getNextReceiptNumber(ledger_id: str)
- createEGGiftImport(eg_gift_import: EGGiftImport, ledger_id: str)
- createBatchWithGifts(ledger: str, period: str, gifts: List[Gift], desc: str = None, date: datetime = None, posted: bool = False, posted_to_gl: bool = False)

`createBatchWithGifts` is the composite "record a batch of gifts" flow — it creates the batch, then each `Gift` (auto-filling ledger/batch/gift numbers, defaulting the amount from its `item_objects`, and looking up a receipt number via `getNextReceiptNumber` if one wasn't set), then each gift's `GiftItem`s. Unlike `Disbursements.createBatchWithLineItems`/`GL.createJEWithTrans`, **it does not raise if batch creation fails** — it silently returns `{"Batch": <response>, "Gifts": []}`, so check `result["Batch"].status_code` yourself:

```python
from kardia.objects.gift_objects import Gift, GiftItem
from decimal import Decimal

item = GiftItem(fund="F1", account_code="5000", amount=Decimal("10.00"))
gift = Gift(amount=Decimal("10.00"), partner="P1", item_objects=[item])

result = kardia.gift.createBatchWithGifts("L1", "2024.01", [gift], desc="Sunday offering")
if result["Batch"].status_code != 201:
    raise RuntimeError(f"Batch creation failed: {result['Batch'].content}")
for gift_result in result["Gifts"]:
    print(gift_result["Gift"].status_code, [r.status_code for r in gift_result["Items"]])
```

### GL
```python
>>> kardia.gl
```
#### Methods
- getAccounts(ledger_id: str)
- getAccount(ledger_id: str, account_id: str)
- createAccount(account: Account)
- getAllFunds(ledger_id)
- getFunds(ledger_id)
- getFund(ledger_id, fund_id)
- createFund(fund: Fund)
- getSubfunds(ledger_id, fund_id)
- getSubfund(ledger_id, fund_id, subfund_id)
- createSubFund(fund: Fund)
- getBatches(ledger_id, year_id, period_id)
- getBatch(ledger_id, year_id, period_id, batch_id)
- getNextBatchId(ledger_id)
- createBatch(year_id, batch: Batch)
- getPeriods(ledger_id, year_id)
- getPeriod(ledger_id, year_id, period_id)
- createPeriod(period: Period)
- getYears(ledger_id: str)
- getYear(ledger_id: str, year_id: str)
- createYear(year: Year)
- createMonth(ledger_id: str, year_id: str, month: int, month_id: str = None, status: PeriodStatuses = PeriodStatuses.NEVER_OPENED)
- getTransactions(ledger_id, year_id, period_id, batch_id)
- createJEWithTrans(ledger_id, year_id, period_id, transactions: List[Transaction], journal_id=1, batch_id=None, date=None, desc="", post=True)
- createTransaction(year_id: str, trans: Transaction)
- createTransactions(year_id: str, transactions: List[Transaction])
- getTransaction(ledger_id: str, year_id: str, period_id: str, batch_id: int, trans_id: int, journal_id: int = 1)
- getAnalysisAttributes(ledger_id: str)
- getAnalysisAttribute(ledger_id: str, attr_code: str)
- createAnalysisAttribute(analysis_attribute: AnalysisAttribute)
- getAnalysisAttributeValues(ledger_id: str)
- getAnalysisAttributeValue(ledger_id: str, attr_code: str, value: str)
- createAnalysisAttributeValue(analysis_attribute_value: AnalysisAttributeValue)
- getAccountAnalysisAttributes(ledger_id: str)
- getAccountAnalysisAttribute(ledger_id: str, account_code: str, attr_code: str)
- createAccountAnalysisAttribute(account_analysis_attribute: AccountAnalysisAttribute)
- getAccountAccountAnalysisAttributes(ledger_id: str, account_code: str)
- getFundAnalysisAttributes(ledger_id: str)
- getFundAnalysisAttribute(ledger_id: str, fund_id: str, attr_code: str)
- createFundAnalysisAttribute(fund_analysis_attribute: FundAnalysisAttribute)
- getFundFundAnalysisAttributes(ledger_id: str, fund_id: str)

`createJEWithTrans` is the composite "create a batch (or use an existing one) plus its transactions" flow: it looks up the next batch ID (unless `batch_id` is given), creates the batch, then creates each transaction, raising if any step fails. `createBatchWithLineItems` (see [Disbursements](#markdown-header-disbursements)) is the disbursement-ledger equivalent and additionally posts balancing GL transactions when `gl_post=True`.

### Partner
```python
>>> kardia.partner
```
#### Methods
- getPartners()
- getPartner(partner_id: str)
- createPartner(partner: Partner)
- getPartnerAddresses(partner_id: str)
- getPartnerAddress(partner_id, location_id: str)
- getNextPartnerAddressID(partner_id: str)
- createPartnerAddress(address: Address)
- getPartnerContactInfos(partner_id: str)
- getPartnerContactInfo(partner_id: str, contact_id: str)
- getNextPartnerContactInfoID(partner_id: str)
- createPartnerContactInfo(contact_info: ContactInfo)
- getPartnerSubscriptions(partner_id: str)
- getPartnerSubscription(partner_id: str, list_id: str)
- getStaff()
- createStaff(staff: Staff)
- getStaffMember(staff_id: str)
- getStaffLogins()
- getStaffLogin(username: str)
- getContactTypes()
- getContactType(contact_type)
- getTests()
- getTest(test)
- getNextPartnerKey()

`createPartner` is a composite call: it creates the `Partner` (auto-generating a key via `getNextPartnerKey` if `partner.p_partner_key` is `None`), then its `staff_object`, then each of its `address_objects`/`contact_info_objects` — cascading the partner key onto every sub-object first. It returns a dict of the individual responses, e.g. `{"Partner": <Response>, "Staff": <Response>, "Addresses": [<Response>, ...], "ContactInfos": [<Response>, ...]}` (plus `"NextPartnerKey"` if a key had to be generated).

### Report
```python
>>> kardia.report
```
#### Methods
- getReport(report_file: str, report_params: Dict[str, str])
- getSchedReportsToBeSent()
- getSchedReportParams(sched_report_name)
- updateSchedReportStatus(sched_report_name, sched_report_status: SchedReportStatus)
- getSchedReportBatches()
- updateSchedReportBatchStatus(sched_report_batch_name, sched_report_batch_status: SchedReportBatchStatus)

`getReport` runs a `.rpt` report file directly (e.g. `report_file="rcpt/fund_gift_list.rpt"`) against the bare Kardia URL (not `/api`), passing `report_params` as query params.

### System Config
`modules/sys_config.py` defines a `SysConfig` class (`getConfig(ledger)`), but it is **not currently wired up** — the `sys_config` property on `Kardia` is commented out in `kardia.py`, so `kardia.sys_config` doesn't work. Use `kardia.register_module("sys_config", sys_config.SysConfig)` (see [Custom Modules](#markdown-header-custom-modules)) if you need it before it's re-enabled.

## Custom Modules
---
Register your own module to reach an endpoint that isn't covered by a built-in module. A custom module is a normal `APIModule` subclass, written the same way as any built-in module (see e.g. `kardia/modules/config.py`):

```python
from enum import Enum
from kardia.modules.api_module import APIModule

class Endpoints(Enum):
    PARENT = None
    THING = "Thing"

class MyModule(APIModule):
    def __init__(self, kardia_url, user, pw):
        super().__init__(kardia_url, user, pw)
        self.module = "mymodule"

    def getThing(self, thing_id):
        return self._getEndpoint(Endpoints.THING, thing_id)
```

```python
>>> kardia.register_module("my_module", MyModule)
>>> kardia.my_module.getThing("T1")
```

`register_module` also returns the constructed instance, so you can capture it in a typed local variable for IDE autocomplete/type-checking instead of relying on `kardia.<name>` (which, being resolved dynamically, isn't visible to static analysis):

```python
>>> my_module: MyModule = kardia.register_module("my_module", MyModule)
>>> my_module.getThing("T1")  # autocompletes, since my_module is now a plain MyModule reference
```

`kardia.<name>` and the instance returned by `register_module` are the same object, constructed once and cached — subsequent access via either form never re-constructs it. `register_module` raises `TypeError` if `module_class` isn't an `APIModule` subclass, and `ValueError` if `name` collides with an existing `Kardia` attribute.

## URI Params
---
Kardia uses vairous URI parameters to determine what type should be returned and how it is formatted. See [Centrallix REST Interface URI Parameters](https://github.com/LightSys/centrallix/blob/b9d04ed04bdd8b194790e74f2ee67f4c7bc3c500/centrallix-sysdoc/JSON-REST.md#rest-interface-uri-parameters). There are four ways that these paramaters can be set.

### Auto
API modules will by default try to determine the correct URI params.

```python
>>> res = kardia.gl.getFunds("LEDGER")
>>> res.url
.../api/gl/LEDGER/Funds/?cx__mode=rest&cx__res_type=collection&cx__res_attrs=basic

>>> res = kardia.gl.getFund("LEDGER", "10001")
>>> res.url
.../api/gl/LEDGER/Funds/10001|LEDGER?cx__mode=rest&cx__res_format=attrs
```

### Type Properties
API modules can set the *res_type* (and, for **element**, that type's *res_format*) with the three properties: **collection**, **element**, and **both**. This allows for chaining methods.

```python
>>> res = kardia.gl.element.getFunds("LEDGER")
>>> res.url
.../api/gl/LEDGER/Funds/?cx__mode=rest&cx__res_type=element&cx__res_format=attrs

>>> res = kardia.gl.collection.getFund("LEDGER", "10001")
>>> res.url
.../api/gl/LEDGER/Funds/10001|LEDGER?cx__mode=rest&cx__res_type=collection&cx__res_attrs=basic

>>> res = kardia.gl.both.getFund("LEDGER", "10001")
>>> res.url
.../api/gl/LEDGER/Funds/10001|LEDGER?cx__mode=rest&cx__res_type=both
```

### Builder Methods
API modules also have chainable methods for setting each URI param individually: **res_type(value)**, **res_mode(value)**, **res_format(value)**, **res_attrs(value)**, **res_levels(value)**. These are useful when the Type Properties above don't cover the specific combination of params you need — e.g. setting *res_attrs* without setting every other param via `setParams()`.

```python
>>> res = kardia.gl.res_type("collection").res_format("attrs").res_attrs("full").res_levels(2).getFunds("LEDGER")
>>> res.url
.../api/gl/LEDGER/Funds/?cx__mode=rest&cx__res_type=collection&cx__res_format=attrs&cx__res_attrs=full&cx__res_levels=2
```

### Params Method
API modules have a *setParams()* method which allows for setting any and all of the URI params. This also allows for chaining methods.

```python
>>> res = kardia.gl.setParams(res_type="collection", res_format="attrs", res_attrs="full", res_levels=1).getFunds("LEDGER")
>>> res.url
.../api/gl/LEDGER/Funds/?cx__mode=rest&cx__res_type=collection&cx__res_format=attrs&cx__res_attrs=full
```

## Development and Testing
---
Install editable with test dependencies, then run the mock suite (fast, no live server needed):

```console
$ python -m pip install -e ".[test]"
$ python -m unittest discover -s tests -p "test_*_mock.py"
```

Running the full `python -m unittest discover` also picks up the live integration suite, which needs a real Kardia server and `tests/config.toml` (copy `tests/config.template` and fill in credentials/fixture IDs) — see `CLAUDE.md` for the full testing setup and architecture notes.
