Metadata-Version: 2.4
Name: kardia
Version: 0.0.41
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 akey 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
from .api_module import RootEndpoint

class Endpoints(Enum):
    PARENT = RootEndpoint.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` → `RootEndpoint.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
```

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 via a side request when the module instance is constructed, sent as `cx__akey` on every GET/POST/PATCH, and 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:

- **`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: Decimal = Decimal("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
```
Wraps Kardia's `/api/disbursements` tree (ledgers, then `Periods/<year>/<month>`, then `Batches/<batch>`, then `Transactions` (cash disbursement line items) and a `Post` action). Batch numbers come from the GL module, so `createBatch` delegates to an internal `GL` instance (`kardia.disb.gl`).

#### Methods
- getLedgers()
- getLedger(ledger: str)
- getYears(ledger: str)
- getPeriods(ledger: str, year: str)
- getBatches(ledger: str, year: str, month: str)
- getBatch(ledger: str, year: str, month: str, batch: int)
- getNextBatchId(ledger_id: str) — looked up through the GL module. Any request params set on `kardia.disb` (e.g. `.both`) are forwarded to that GL request.
- createBatch(year: str, batch: Batch) — forces `a_origin = "CD"`
- 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])
- postBatch(ledger: str, year: str, month: str, batch: int) — posts the batch to the disbursements ledger and the GL in one server-side step. Every line item must be unposted. The response has `posted` (1 on success) and `errors`.
- createBatchWithLineItems(ledger: str, year: str, month: str, line_items: List[LineItem], desc: str = "", date: datetime = None, post: bool = False) — creates the batch and its unposted line items, then calls `postBatch` if `post=True`. Raises if batch or line item creation fails, or if posting does not succeed.

`createBatchWithLineItems` fills in each `LineItem`'s ledger, period, batch number, `a_disbursement_id` (always `1`), line item number (1-based, in list order), and effective date, and forces `a_posted`/`a_posted_to_gl` to `0` so the batch can be posted server-side. It returns `{"batch": <response>, "line_items": [<response>, ...], "post": <response, or None if post=False>}`:

```python
from kardia.objects.disb_objects import LineItem
from decimal import Decimal

item = LineItem(cash_account="1000", amount=Decimal("25.00"), fund="F1", account="5000", payee="100001")

result = kardia.disb.createBatchWithLineItems("L1", "2024", "01", [item], desc="Reimbursements", post=True)
print(result["batch"].get("a_batch_number"), result["post"].get("posted"))  # e.g. 5 1
```

### 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)
- postBatch(ledger_id, year_id, period_id, batch_id) — posts the batch in one server-side step: creates IXA, verifies the batch, then posts it if verification passes. The response has `posted` (1 on success) and `errors`.
- 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 (unposted), raising if any step fails. With `post=True` (the default) it then calls `postBatch` and raises if the batch is not posted. The returned dict has `batch`, `transactions`, and `post` (`None` if `post=False`). `createBatchWithLineItems` (see [Disbursements](#markdown-header-disbursements)) is the disbursement-ledger equivalent; with `post=True` it uses the server-side `Post` action, which also posts to the GL.

### 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.
