Metadata-Version: 2.5
Name: calendry-client
Version: 0.2.0
Summary: Python client library for the Calendry scheduling API
Project-URL: Homepage, https://github.com/Calendry-de/Calendry-Importlib
Project-URL: Repository, https://github.com/Calendry-de/Calendry-Importlib
Author: Calendry
License: MIT
Requires-Python: >=3.9
Requires-Dist: requests>=2.28
Provides-Extra: xlsx
Requires-Dist: openpyxl>=3.1; extra == 'xlsx'
Description-Content-Type: text/markdown

# calendry-client

[![CI](https://github.com/Calendry-de/Calendry-Importlib/actions/workflows/ci.yml/badge.svg)](https://github.com/Calendry-de/Calendry-Importlib/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/calendry-client.svg)](https://pypi.org/project/calendry-client/)
[![Python versions](https://img.shields.io/pypi/pyversions/calendry-client.svg)](https://pypi.org/project/calendry-client/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#license)

Python client library for the [Calendry](https://github.com/Calendry-de) scheduling API. It wraps every core resource (persons, groups, rooms, offerings, offering templates & plans, equipment, roles, terms, time grids, session kinds, calendar periods, constraints, access roles) as a small set of **pure functions** — `add_person`, `add_offering_template`, `apply_offering_plan`, and so on — plus a ready-to-run script that imports a full curriculum planning workbook (Offerings, Rooms, People, Groups, and curriculum plans).

The full HTTP surface is documented in [`swagger.json`](swagger.json); the library's functions build the request bodies described there. There is no code generation step — the swagger file is the reference the modules were written against, not something they're built from.

## Contents

- [Features](#features)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Configuring the server URL and token](#configuring-the-server-url-and-token)
- [API coverage](#api-coverage)
- [Working with relations](#working-with-relations)
- [Error handling](#error-handling)
- [Checking permissions](#checking-permissions)
- [Importing an xlsx planning workbook](#importing-an-xlsx-planning-workbook)
- [Development](#development)
- [Publishing](#publishing)
- [License](#license)

## Features

- One small `CalendryClient` HTTP wrapper — no ORM, no hidden state, no generated boilerplate.
- A plain function per operation, taking the client as its first argument and returning the parsed JSON response.
- Optional keyword arguments left as `None` are simply omitted from the request body (never sent as JSON `null`).
- Relation endpoints (`offerings/lecturers`, `groups/terms`, `rooms/equipment`, ...) get typed `get_*`/`set_*` helpers; `set_*` always **replaces** the whole membership set, mirroring the API's `PUT`.
- Server URL and auth token are runtime configuration everywhere (constructor args, `CalendryClient.from_env()`, or CLI flags on the import script) — never hard-coded.
- Includes a reference import script (`scripts/import_xlsx.py`) demonstrating the library against a real Offerings/Rooms/People/Groups workbook, with a `--dry-run` mode. It's a plain script, not part of the distributed `calendry-client` package — run it from a checkout of this repo.

## Installation

```bash
pip install calendry-client

# with openpyxl, needed only for scripts/import_xlsx.py:
pip install "calendry-client[xlsx]"
```

Requires Python 3.9+. The only runtime dependency is [`requests`](https://pypi.org/project/requests/).

## Quick start

```python
from calendry_client import CalendryClient, add_person, add_offering, set_offering_lecturers

client = CalendryClient(base_url="https://calendry.example.com", token="...")

person = add_person(client, "Ada", "Lovelace", email="ada@example.com")
offering = add_offering(client, term_id="term-1", kind_id="kind-1", title="Intro to CS")
set_offering_lecturers(client, offering["id"], [{"person_id": person["id"]}])
```

Every function returns the raw dict (or list of dicts) the API responds with — e.g. `person["id"]` is the newly created row's id.

## Configuring the server URL and token

Both values can be passed explicitly:

```python
client = CalendryClient(base_url="https://calendry.example.com", token="my-token")
```

or read from the environment (`CALENDRY_SERVER_URL` / `CALENDRY_API_TOKEN` by default, both overridable):

```python
client = CalendryClient.from_env()
client = CalendryClient.from_env(url_var="MY_URL_VAR", token_var="MY_TOKEN_VAR")
```

`token=None` sends unauthenticated requests, which is only useful against a server/route that doesn't require one. The token is sent as `Authorization: Bearer <token>`.

`scripts/import_xlsx.py` exposes the same configuration as `--server-url`/`--token` CLI flags, also falling back to the environment variables above — see [Importing an xlsx planning workbook](#importing-an-xlsx-planning-workbook).

## API coverage

Every resource module lives directly under `calendry_client` and follows the same shape: `add_x` (create), `get_x`, `list_xs`, `update_x`, `delete_x`, plus relation helpers where the API exposes a `/…/{id}/{relation}` route.

| Module (`calendry_client.*`) | Resource | Create | Relations |
|---|---|---|---|
| `persons` | `persons` | `add_person` (alias: `add_lecturer`) | `roles`, `groups`, `access-roles` |
| `roles` | `roles` | `add_role` | — |
| `groups` | `groups` | `add_group`, `add_subgroup` (sets `parent_group_id`) | `terms`, `sources`, `availability` |
| `rooms` | `rooms` | `add_room` | `equipment` |
| `equipment` | `equipment` | `add_equipment` | — |
| `offerings` | `offerings` | `add_offering` | `groups`, `lecturers`, `equipment`, `rooms` |
| `offering_templates` | `offering-templates` | `add_offering_template` | `lecturers` (the eligible pool for the shape) |
| `offering_plans` | `offering-plans` | `add_offering_plan` | `items` (bespoke, ordered — see below), plus `apply_offering_plan` |
| `group_plan_applications` | — (read/derived) | — | `list_group_plan_applications`, `get_group_plan_application`, `advance_all_group_plan_applications` |
| `terms` | `terms` | `add_term` | — |
| `time_grids` | `time-grids` | `add_time_grid` | `breaks` |
| `session_kinds` | `session-kinds` | `add_session_kind` | — |
| `calendar_periods` | `calendar-periods` | `add_calendar_period` | — |
| `constraints` | `constraints` | `add_constraint` | `scopes` |
| `access_roles` | `access-roles` | `add_access_role` | — |

**Curriculum plans**, briefly: an `offering-template` is a reusable, term-independent course shape (no `termId`, no groups/lecturers). An `offering-plan` bundles templates in order (`set_offering_plan_items`, a bespoke `PUT` because order matters — not the generic relation mechanism) and can chain to a successor via `next_plan_id` to model progression (e.g. semester 1 → semester 3 → semester 5). `apply_offering_plan(client, plan_id, term_id, group_id=... )` (or `group_ids=[...]` for the bulk form) is what actually generates the real, schedulable `offerings` for a term and attaches the given Group(s) — reuse is keyed on `term + createdFromTemplateId`, so applying the same template to multiple groups (or from multiple plans, for a cross-listed course) shares one Offering instead of duplicating it. Separately, `add_group`/`update_group` accept `curriculum_plan_id` — a purely administrative "this group intends to follow this plan" hint, never derived from or resolved against the group's actual offerings, so setting it does **not** generate anything; only `apply_offering_plan` does that.

Every one of these is also re-exported from the top-level `calendry_client` package, so `from calendry_client import add_room, set_room_equipment` works without knowing which module it lives in.

For anything not covered by a named convenience function (an unusual filter, a resource-specific field), the generic building blocks are always available:

```python
from calendry_client import CalendryClient
from calendry_client._generic import list_rows, create_row, update_row, delete_row, get_relation, set_relation

list_rows(client, "offerings", term_id="term-1")
create_row(client, "offerings", {"termId": "term-1", "kindId": "kind-1", "title": "Ad-hoc offering"})
```

## Working with relations

Relation setters replace the **entire** membership set in one call — there's no per-row add/remove, matching the API's `PUT` semantics:

```python
from calendry_client import add_group, add_subgroup, set_offering_groups, set_person_roles

cohort = add_group(client, "dWI24-A")
section = add_subgroup(client, cohort["id"], "dWI24-A1", expected_size=28)

set_offering_groups(client, offering["id"], [section["id"]])
set_person_roles(client, person["id"], [lecturer_role["id"]])
```

## Error handling

Any non-2xx response raises `CalendryAPIError`, carrying the HTTP status code and the parsed error payload (when the response was JSON):

```python
from calendry_client import CalendryAPIError, add_room

try:
    add_room(client, code="R-101", name="Room 101")
except CalendryAPIError as exc:
    print(exc.status_code, exc.payload)
```

## Checking permissions

`get_session(client)` wraps `GET /api/auth/session` (identity, active tenant, and the permission keys the active Person holds). `missing_permissions(client, required)` checks a list of keys against it and returns the ones not held — empty means everything's covered. Use it as a preflight check before a batch of writes, so a missing permission surfaces immediately instead of partway through (every route re-checks server-side regardless, so this is a fail-fast convenience, not a security boundary):

```python
from calendry_client import missing_permissions

missing = missing_permissions(client, ["persons.create", "groups.create", "offering_plan.apply"])
if missing:
    raise SystemExit(f"Token is missing: {', '.join(missing)}")
```

Generic CRUD permissions follow `<resource>.create`/`.read`/`.update`/`.delete`, where `<resource>` is the URL path segment (e.g. `offering-templates.create`); a few actions use their own keys instead (`access_role.manage`, `person_access_role.assign`, and the curriculum-plan bespoke actions `offering_plan.update`/`offering_plan.apply`).

## Importing an xlsx planning workbook

`scripts/import_xlsx.py` is a **reference script**, not an installable package or a `calendry-client` entry point — it's kept in this repo purely to demonstrate the library end-to-end and isn't published to PyPI. It reads a Calendry planning workbook — Offerings, Rooms, People, Groups and sub-groups, in the shape of [`Test_anonymized.xlsx`](Test_anonymized.xlsx) — and creates every row through the library above. It locates sheets by header row (not by name), so re-ordering or renaming sheets is fine as long as the columns are there.

On a live run (not `--dry-run`), it first checks the token holds every permission the import could need — via `missing_permissions` (see [Checking permissions](#checking-permissions)) — and aborts before writing anything if it doesn't, naming exactly what's missing. If that check itself fails (some servers don't accept an API token on `/api/auth/session` even though the resource routes this import uses accept it fine), it prints a warning and proceeds rather than crashing or blocking on an unrelated diagnostic failure.

```bash
python scripts/import_xlsx.py \
    --server-url https://calendry.example.com \
    --token "$CALENDRY_API_TOKEN" \
    --input Test_anonymized.xlsx \
    --dry-run   # preview only; drop this flag to actually write
```

| Flag | Required | Default | Purpose |
|---|---|---|---|
| `--input PATH` | yes | — | Workbook to import |
| `--server-url URL` | yes* | `$CALENDRY_SERVER_URL` | Calendry server base URL |
| `--token TOKEN` | no | `$CALENDRY_API_TOKEN` | Bearer token; omitted requests are sent unauthenticated with a warning |
| `--term-start-date` / `--term-end-date` | no† | derived from the weeks sheet | Override the term's dates; only valid when the sheet has a single `Semester` value |
| `--default-frequency` | no | `1` | Fallback sessions/week for templates with no `KS` value |
| `--default-duration-blocks` | no | `1` | Duration in grid blocks for created offerings (not derived from the sheet) |
| `--block-length-minutes` | no | `195` | Time-grid block length in minutes; also used for converting `KS` into `frequency` |
| `--teaching-weeks` | no | `20` | Assumed teaching weeks per term, for converting `KS` into `frequency` |
| `--no-time-grid` | no | off | Skip provisioning the default time grid, and don't set it on created terms |
| `--time-grid-name` | no | `Standard FH-Grid` | Name of the default time grid |
| `--blocks-per-day` | no | `3` | Default time grid: blocks per day |
| `--start-hour` / `--start-minute` | no | `9` / `0` | Default time grid: start of day |
| `--time-grid-gap-minutes` | no | `0` | Default time grid: default gap between blocks |
| `--teaching-days` | no | `1,2,3,4,5,6` | Default time grid: comma-separated ISO weekdays (1=Monday) that are active |
| `--dry-run` | no | off | Make no API calls at all; write the plan to a CSV file instead (see below) |
| `--dry-run-csv PATH` | no | `dry_run_plan.csv` | With `--dry-run`, where to write the plan |

\* required unless set via the environment variable or `--dry-run` (a dry run never contacts a server, so none is needed).
† required if the workbook has no weeks sheet (rooms and weeks sheets are both optional — a workbook can be offerings-only).

With `--dry-run`, every planned create and relation update is written to the CSV instead of being sent to the API — columns are `action` (`create`/`set_relation`), `entity_type`, `key` (the natural key used for de-duplication), `id` (the fake id assigned for the run), and `details` (target ids for relation rows). Open it in a spreadsheet to review the full plan before pointing the script at a real server.

The import goes through the curriculum-plan mechanism rather than creating `offerings` directly: **offering-templates → offering-plans (one per cohort, chained) → apply (generates the real Offerings) → lecturers attached**. What gets imported, from which columns:

- **Rooms** (skipped entirely if there's no rooms sheet) — `Name Raum`, `Anzahl Personen` (capacity), `Anzeigen Grid` (`isActive`). `Prio` is lower-is-better in the sheet (100 = a premium room, 900 = the least desirable) — the opposite of Calendry's `ranking` (higher is better) — so every value is flipped by reflecting it across the sheet's own min/max range (`new = min + max - value`) before becoming the room's `ranking`. A room named `Online` is marked `isVirtual`. `Ausstattung` (e.g. `Hybrid`) describes the room's delivery mode, not physical equipment, and has no dedicated room field either, so it's not imported (logged instead) — same as `Buchung verursacht Raumkonflikte`, which also has no equivalent Calendry resource.
- **People** — the `Name` (lecturer) column, deduplicated by name across all rows.
- **Groups & sub-groups** — the `Planungsgruppe` column: the first 3 letters are a curriculum, followed by a year, optionally followed by `-` and a concrete group (e.g. `dWI24-A1` → curriculum `dWI`, cohort `dWI24`, group `dWI24-A1`; `dBA24` with no group suffix → the cohort `dBA24` itself is the leaf group). The **cohort** (curriculum+year) is what an offering-plan is built from. A group suffix may contain `/` (e.g. `dWI25-A1/2`, a split section), and a cell may list several groups sharing one session joined by `#` (e.g. `dBY25-AUT#dBY25-IND`, or `dWI24#dIT24` for a course shared across two curricula) — each becomes its own group. A code that doesn't match the curriculum+year shape at all becomes a flat top-level group with no plan membership. `Pax` ("how many people this group holds") becomes each leaf group's `expectedSize`.
- **Offering templates** — one per distinct `(Semester, Veranstaltung Abk, is it Online)`. Rows sharing a template contribute to it: every distinct `Name` lecturer across those rows is attached directly to the template (`set_offering_template_lecturers`, the eligible pool for the shape) and their groups get attached to the real Offering once the template's plan is applied — that Offering also gets the same lecturer set again directly, as a safety net. A course that appears with both an `Online = Ja` row and a non-online row becomes **two** templates (e.g. `For` and `For (Online)`) — a genuinely different delivery mode, not incidental variation, set via `onlineMode`: `REQUIRED` (only virtual rooms) for the online template, `FORBIDDEN` (excludes them) for the in-person one; otherwise varying `KS` values for the same template are just recorded together. `KS` (contact hours) has no dedicated template field, so every distinct value found across the template's own rows is recorded verbatim in its `notes`, alongside the total session count implied by it (a reference figure only — there's no API field for it) and the computed `frequency`. Both come from `hours_per_session = --block-length-minutes * --default-duration-blocks / 60` (one session is assumed to occupy `--default-duration-blocks` grid blocks): total sessions = `round(KS / hours_per_session)`, `frequency` (sessions/week) = `round(KS / hours_per_session / --teaching-weeks)` (minimum 1) — a template with no `KS` falls back to `--default-frequency`. `Prüfungstermin` is not imported. Rows with `Ist_vorlesung` = `Nein` are not lectures and are **skipped entirely** — no template, group, or person is created from them.
- **Curriculum plans** — one `offering-plan` per cohort, holding exactly the templates that appeared with that cohort's groups (via `set_offering_plan_items`), and chained via `nextPlanId` newest-intake-year-first within each curriculum (e.g. `dWI26` → `dWI25` → `dWI24`) so `advance-all` can move a Group forward as it progresses. Each cohort's leaf/section groups (e.g. `dWI24-A1`, `dWI24-A2`) — not the cohort group itself, and not the curriculum-root group — also get that plan recorded as their `curriculumPlanId`: an administrative hint, separate from and unaffected by the actual apply step below.
- **Applying** — each cohort's plan is applied (`apply_offering_plan`) for its term with every one of that cohort's groups attached in one bulk call; the resulting real Offerings (deduplicated by `term + createdFromTemplateId`, so a cross-listed course's plan-apply from two different cohorts converges on the same Offering) then get their template's lecturers attached via `set_offering_lecturers`.

The term itself is looked up/created from the `Semester` column, with start/end dates derived from the weeks sheet (matching `Wochennummer` values against the `Semester` string) unless overridden with `--term-start-date`/`--term-end-date`. Unless `--no-time-grid` is given, a default time grid (`--time-grid-name`, `isDefault`, with a 15-minute "Break" after block 1 and a 45-minute "Mittagspause" after block 2) is also provisioned from `--block-length-minutes`/`--blocks-per-day`/`--start-hour`/`--start-minute`/`--time-grid-gap-minutes`/`--teaching-days`, and set as every created term's `timeGridId`.

The script is idempotent: it lists existing rows before creating anything and reuses matches (by name/code/key, as appropriate), applying is itself idempotent per the API's own semantics, so re-running it against the same server does not create duplicates. Offering-template `frequency`/`notes`/`onlineMode` are the one exception to "reuse means leave alone": an existing template's values are always reconciled to what this run recomputes (e.g. from `KS`, or from `--block-length-minutes`/`--teaching-weeks` if those flags changed since the template was first created), rather than staying frozen at whatever they were the first time.

## Development

```bash
pip install -e ".[xlsx]" pytest
pytest
```

## Publishing

Pushing a `vX.Y.Z` tag runs [`.github/workflows/publish.yml`](.github/workflows/publish.yml), which builds the sdist/wheel and publishes them to PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (configure a `pypi` GitHub environment as the trusted publisher for this repository, or swap in a `PYPI_API_TOKEN` secret and pass it to the publish step). [`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs the test suite and a build check on every push and pull request. The package version is derived from git tags (via `hatch-vcs`) — there is no version number to bump by hand.

## License

MIT.
