Schnellstart
Diese Seite führt ein Projekt vom leeren Verzeichnis bis zum Skript, das Objekte liest und schreibt:
Installation, Entscheidung zwischen der sync- und der async-Variante, Verbindungsaufbau, typisierte
Modelle, ein select(), eine direkte SQL-Abfrage, ein upsert(), ein Dokument samt Datei, ein
Kontextwechsel auf einen anderen Benutzer und die Fehlerbehandlung.
Jedes Codebeispiel liegt in beiden Varianten vor. Der zum Projekt passende Tab wird einmal gewählt und bleibt für den Rest der Seite ausgewählt.
-
Python >= 3.12
-
uv oder pip
-
Hostname, Port (Standard
4000) und Zugangsdaten des enaio-Servers -
TCP-Zugang vom ausführenden Rechner zu diesem Server
1. Installation
-
uv
-
pip
uv add ecmind-blue-client
pip install ecmind-blue-client
1.1. Neues Projekt von Null aufsetzen
uv init erzeugt das Projektgerüst, uv add löst die Abhängigkeit auf und schreibt sie in
pyproject.toml und uv.lock:
uv init invoice-import
cd invoice-import
uv add ecmind-blue-client
Verzeichnis danach:
invoice-import/
├── main.py
├── pyproject.toml
├── README.md
└── uv.lock
Die virtuelle Umgebung wird vom ersten uv-Kommando erzeugt, das sie benötigt, es muss also nichts
manuell aktiviert werden. Ausgeführt wird das Projekt mit uv run:
uv run main.py
uv add schreibt die aufgelösten Versionen nach uv.lock. Diese Datei gehört in die
Versionsverwaltung, damit auf jedem Rechner und in jedem Build exakt dieselben Versionen installiert
werden.
|
2. Sync oder Async?
Die Bibliothek bietet zwei API-Varianten, die sich in der Ausführungsweise unterscheiden:
Sync (SyncPoolClient) |
Async (AsyncPoolClient) |
|---|---|
Blockiert den aufrufenden Thread bis die Antwort vorliegt. |
Gibt die Kontrolle an den Event Loop zurück, während auf die Antwort gewartet wird. |
Einfacher, linearer Code ohne |
Erfordert |
Geeignet für Skripte, Batch-Prozesse und Importer. |
Geeignet für Web-Anwendungen und andere Event-Loop-basierte Systeme. |
Sync eignet sich überall dort, wo der Code sequenziell läuft und keine parallele Verarbeitung nötig ist — zum Beispiel in Importskripten, Migrationen oder Kommandozeilen-Werkzeugen.
Async ist die richtige Wahl, wenn der Client in einen bestehenden Event Loop eingebunden wird, etwa in FastAPI-Endpunkten. In diesem Fall würde ein blockierender Sync-Client den gesamten Event Loop blockieren und damit alle parallelen Anfragen verzögern.
-
Sync
-
Async
# Importskript: linearer Code, kein Event Loop
ecm = ECM(SyncPoolClient(servers="<host>:4000:1", username="<user>", password="<pass>"))
for folder in ecm.dms.select(InvoiceFolder).stream():
print(folder.system.id, folder.Title)
# FastAPI-Endpunkt: in einem bestehenden Event Loop
ecm = ECM(AsyncPoolClient(servers="<host>:4000:1", username="<user>", password="<pass>"))
@app.get("/folders")
async def list_folders():
return [
{"id": folder.system.id, "title": folder.Title}
async for folder in ecm.dms.select(InvoiceFolder).stream()
]
3. Verbindung herstellen
-
Sync
-
Async
from ecmind_blue_client.ecm import ECM
from ecmind_blue_client.pool import SyncPoolClient
client = SyncPoolClient(
servers="<host>:4000:1",
username="<username>",
password="<password>",
)
ecm = ECM(client)
from ecmind_blue_client.ecm import ECM
from ecmind_blue_client.pool import AsyncPoolClient
client = AsyncPoolClient(
servers="<host>:4000:1",
username="<username>",
password="<password>",
)
ecm = ECM(client)
Das Format für servers ist <host>:<port>:<gewichtung>, mehrere Server werden durch getrennt.
Die Gewichtung bestimmt die Verteilung der Verbindungen, "<host1>:4000:2<host2>:4000:1" schickt also
doppelt so viele Verbindungen an den ersten wie an den zweiten Server.
|
Verschlüsselung (
use_ssl)
|
Der Client wird einmal erzeugt und über die gesamte Laufzeit wiederverwendet, nicht pro Operation neu.
Bei einem kurzen Skript kann der Pool der Garbage Collection überlassen werden. Ein langlaufender
Prozess sollte ihn explizit mit client.close() (sync) bzw. await client.aclose() (async)
herunterfahren, was die Hintergrund-Worker stoppt und alle unbenutzten Verbindungen schliesst. Das
Gegenstück in einem Web-Service zeigt FastAPI-Integration, das
Offenhalten unbenutzter Verbindungen durch Firewalls und Load-Balancer hindurch
KeepAlive im Pool.
4. Modelle erstellen
Eine Modellklasse beschreibt einen Objekttyp und liefert typisierten Feldzugriff, Code-Completion und
statische Typprüfung. ecm-generate-models ist Teil der Bibliothek und schreibt diese Klassen direkt
aus der Objektdefinition des Servers, eine Datei pro Schrank:
uv run ecm-generate-models \
--host enaio.example.com \
--username admin \
--password secret \
--cabinet Invoices \
--output-dir ./models
Written: models/Invoices.py
Die generierte Datei enthält eine Klasse pro Objekttyp, ein ECMField pro Indexfeld und ein Enum pro
Listenfeld:
class InvoiceFolder(ECMFolderModel):
_internal_name_ = "InvoiceFolder"
Title: ECMField[str] = ECMField(str, mandatory=True)
Year: ECMField[int] = ECMField(int, default=None)
Status: ECMField[InvoiceFolder_StatusEnum] = ECMField(InvoiceFolder_StatusEnum, default=None)
Die Datei wird neu generiert, sobald sich das Schema auf dem Server ändert, und nie manuell
bearbeitet. Jedes Argument des Kommandos, inklusive Generierung aus einer exportierten
asobjdef-XML anstelle eines laufenden Servers, beschreibt
ecm-generate-models.
Wo Generierung nicht in Frage kommt, lässt sich eine Modellklasse auch von Hand schreiben oder
zur Laufzeit allein aus dem internen Namen mit make_folder_model() und seinen Geschwistern
erzeugen. Beides beschreibt ECM-Modell.
|
5. Objekte abfragen
ecm.dms.select() gibt einen Query-Builder zurück: .where() filtert, .order_by() sortiert,
ausgeführt wird die Abfrage mit .stream() oder .execute().
stream()-
Liest die Ergebnisse seitenweise vom Server und liefert jedes Objekt einzeln. Empfohlen für große Ergebnismengen, da nie alle Objekte gleichzeitig im Speicher gehalten werden.
execute()-
Ruft alle Ergebnisse vollständig ab und gibt eine Liste zurück. Praktisch bei kleinen Ergebnismengen oder wenn die Gesamtanzahl vorab benötigt wird.
|
|
-
Sync
-
Async
from models.Invoices import InvoiceFolder
for folder in (
ecm.dms.select(InvoiceFolder)
.where(InvoiceFolder.Year >= 2024)
.order_by(InvoiceFolder.Year.DESC)
.stream()
):
print(folder.system.id, folder.Title, folder.Year)
from models.Invoices import InvoiceFolder
async for folder in (
ecm.dms.select(InvoiceFolder)
.where(InvoiceFolder.Year >= 2024)
.order_by(InvoiceFolder.Year.DESC)
.stream()
):
print(folder.system.id, folder.Title, folder.Year)
.execute() liefert dieselben Treffer stattdessen als Liste, praktisch wenn die Gesamtzahl vorab
benötigt wird: folders = ecm.dms.select(InvoiceFolder).where(InvoiceFolder.Year == 2024).execute(),
in der async-Variante mit await vor der Kette.
Mehrere Bedingungen in einem .where()-Aufruf werden mit AND verknüpft, & und | verknüpfen sie
explizit. Beide Operatoren binden in Python stärker als ==, jeder Vergleich braucht daher eigene
Klammern, und or / and sind kein Ersatz: sie würden eine Bedingung verwerfen und werden
deshalb mit TypeError abgelehnt.
query = ecm.dms.select(InvoiceFolder)
query.where((InvoiceFolder.Year == 2024) | (InvoiceFolder.Year == 2025)) # OR-Gruppe
query.where(InvoiceFolder.Year == 2024 | InvoiceFolder.Year == 2025) # TypeError
query.where((InvoiceFolder.Year == 2024) or (InvoiceFolder.Year == 2025)) # TypeError
Alle Builder-Methoden inklusive Paging, Feldbeschränkung und Volltextsuche: select().
6. Ein einzelnes Objekt lesen und ändern
select() sucht, get() holt ein bekanntes Objekt über seine ID:
-
Sync
-
Async
folder = ecm.dms.get(InvoiceFolder, 4711)
print(folder.Title)
folder = await ecm.dms.get(InvoiceFolder, 4711)
print(folder.Title)
Eine nicht existierende ID führt zu einer ECMNotFoundException.
Eine geladene Instanz verfolgt ihre Änderungen selbst, update() schickt daher nur die Felder, die
sich wirklich geändert haben:
-
Sync
-
Async
folder.Title = "Rechnung 2024 (korrigiert)"
print(folder.system.is_modified) # True
print(folder.system.modified_fields) # {'Title': 'Rechnung 2024 (korrigiert)'}
ecm.dms.update(folder)
folder.Title = "Rechnung 2024 (korrigiert)"
print(folder.system.is_modified) # True
print(folder.system.modified_fields) # {'Title': 'Rechnung 2024 (korrigiert)'}
await ecm.dms.update(folder)
Hat sich nichts geändert, unterlässt update() den Serveraufruf ganz, mit force=True wird er
trotzdem gesendet. update_and_get() aktualisiert und gibt die
frische Instanz in einem Aufruf zurück. Wie die Änderungsverfolgung im Detail arbeitet:
ECM-Modell.
7. Direkt per SQL abfragen
ecm.dms deckt die Archivobjekte ab, ecm.security die Benutzer und
Gruppen. Alles darüber hinaus, vor allem Aggregate, Joins und Verwaltungstabellen, ist per SQL über
ado.ExecuteSQL erreichbar:
-
Sync
-
Async
result = ecm.db.select(
"SELECT id, benutzer FROM benutzer WHERE benutzer = %s",
"admin",
)
for row in result:
print(row["benutzer"])
result = await ecm.db.select(
"SELECT id, benutzer FROM benutzer WHERE benutzer = %s",
"admin",
)
for row in result:
print(row["benutzer"])
Werte gehören in Platzhalter (%s für Strings, %d für Ganzzahlen, %u für Bezeichner) und nie in
einen f-String, denn der Server setzt sie quotiert ein und verhindert damit SQL-Injection.
Eine Zeile wird als Rohstring mit row["name"] gelesen oder mit row.typed("name", int)
konvertiert. Alle Platzhalter, die Spaltenmetadaten und der Rückgabetyp:
db.select().
8. Objekte anlegen und aktualisieren
8.1. Anlegen
insert_and_get() legt das Objekt an und gibt es vollständig gefüllt zurück, inklusive der
zugewiesenen ID. Wohin das neue Objekt kommt, hängt von seiner Ebene ab:
| Ebene | Basisklasse | Ablage über |
|---|---|---|
Ordner |
|
nichts, ein Ordner ist die oberste Ebene |
Register |
|
|
Dokument |
|
|
-
Sync
-
Async
from datetime import date
folder = ecm.dms.insert_and_get(InvoiceFolder(Title="Rechnung 2024", Year=2024))
document = ecm.dms.insert_and_get(
InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
folder_id=folder,
)
print(folder.system.id, document.system.id)
from datetime import date
folder = await ecm.dms.insert_and_get(InvoiceFolder(Title="Rechnung 2024", Year=2024))
document = await ecm.dms.insert_and_get(
InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
folder_id=folder,
)
print(folder.system.id, document.system.id)
Ein Dokument kann direkt in einem Ordner liegen, wie hier, oder über register_id in einem seiner
Register. insert() und insert_and_get() akzeptieren für beide entweder eine numerische ID oder die
Modellinstanz selbst, ein frisches Objekt kann also direkt als übergeordnetes Objekt des nächsten
dienen. upsert() nimmt ausschliesslich die numerischen IDs, daher folder.system.id weiter unten.
8.2. Upsert
upsert() überlässt die Entscheidung zwischen Anlegen und Aktualisieren dem Server: .search()
definiert die Bedingungen der Dublettenprüfung, der Server legt bei keinem Treffer neu an und
aktualisiert bei genau einem Treffer. Genau das macht ein Importskript gefahrlos zweimal ausführbar.
.execute() gibt die Objekt-ID, die Objekttyp-ID, die Trefferzahl und die tatsächlich ausgeführte
Aktion zurück:
-
Sync
-
Async
object_id, type_id, hits, action = (
ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
.search(InvoiceFolder.Title == "Rechnung 2024")
.execute()
)
print(object_id, action) # z. B. 4711 INSERT
object_id, type_id, hits, action = await (
ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
.search(InvoiceFolder.Title == "Rechnung 2024")
.execute()
)
print(object_id, action) # z. B. 4711 INSERT
.execute_and_get() liest das entstandene Objekt vom Server zurück und gibt das gefüllte Modell
anstelle des Tupels zurück:
-
Sync
-
Async
folder = (
ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
.search(InvoiceFolder.Title == "Rechnung 2024")
.execute_and_get()
)
print(folder.system.id, folder.Title)
folder = await (
ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
.search(InvoiceFolder.Title == "Rechnung 2024")
.execute_and_get()
)
print(folder.system.id, folder.Title)
.search() verwendet nur Feldname und Wert jeder Bedingung. Der Server vergleicht immer auf
Gleichheit, andere Operatoren als == haben dort keine Wirkung.
|
Die Aktion je Trefferzahl ist konfigurierbar, zum Beispiel nur Anlegen mit .action1("NONE"), und
Dokumente können ihre Datei über .files() mitschicken. Alle Optionen:
upsert().
Zwei clientseitige Regeln greifen beim ersten Schreiben auf einen echten Objekttyp:
- Pflichtfelder
-
Ein mit
mandatory=Truedeklariertes Feld muss einen Wert tragen, sonst löseninsert()und.execute()einenValueErroraus, bevor überhaupt etwas den Server erreicht. Mitcheck_mandatory=Falsewird die Prüfung abgeschaltet. - Schreibgeschützte Felder
-
Ein in der Objektdefinition schreibgeschütztes Feld kann nicht geschrieben werden, der Versuch führt ebenfalls zu einem
ValueError. Beide Regeln beschreibt ECM-Modell.
Bei Datums- und Zeitfeldern kommt eine Entscheidung hinzu. enaio speichert jeden Zeitpunkt als Epoch-Zahl, die es in der Zeitzone des Servers berechnet, und das Protokoll überträgt diese Zeitzone nicht. Sie muss dem Client daher einmal beim Start mitgeteilt werden, sonst verschieben sich Zeitstempel unbemerkt, sobald Server und Hostprozess in unterschiedlichen Zonen liegen:
from ecmind_blue_client import set_server_timezone
set_server_timezone("Europe/Zurich") # oder ECMIND_BLUE_SERVER_TIMEZONE in der Umgebung setzen
Details und die Konvertierungsfunktionen: Zeitzone der Installation.
9. Dokumente mit Datei ablegen
Nur Dokumente tragen Dateien. Eine Datei wird als JobRequestFile übergeben, in einer von drei
Ausprägungen:
| Klasse | Für |
|---|---|
|
eine Datei auf der Festplatte |
|
Inhalt, der bereits im Speicher liegt |
|
einen Stream, etwa einen Upload, ohne ihn vollständig zu puffern |
insert_and_get() nimmt die Dateien als zweites Argument, upsert() über .files(), wo zusätzlich
entschieden wird, ob die übergebenen Dateien die vorhandenen ersetzen oder ergänzen:
-
Sync
-
Async
from ecmind_blue_client.rpc import JobRequestFileFromPath
object_id, type_id, hits, action = (
ecm.dms.upsert(
InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
folder_id=folder.system.id,
)
.search(InvoiceDocument.Title == "Rechnung 4711")
.files([JobRequestFileFromPath("rechnung.pdf")], replace=True)
.execute()
)
from ecmind_blue_client.rpc import JobRequestFileFromPath
object_id, type_id, hits, action = await (
ecm.dms.upsert(
InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
folder_id=folder.system.id,
)
.search(InvoiceDocument.Title == "Rechnung 4711")
.files([JobRequestFileFromPath("rechnung.pdf")], replace=True)
.execute()
)
Beim Zurücklesen liefert files() pro Datei ein JobResponseFile. Kleine Dateien bleiben im
Speicher, größere landen in einer temporären Datei, gelesen werden beide gleich:
-
Sync
-
Async
for response_file in ecm.dms.files(document):
print(response_file.name, response_file.size())
response_file.store("/tmp/" + response_file.name) # auf die Festplatte schreiben
content = response_file.bytes() # oder die Bytes übernehmen
for response_file in await ecm.dms.files(document):
print(response_file.name, response_file.size())
response_file.store("/tmp/" + response_file.name) # auf die Festplatte schreiben
content = response_file.bytes() # oder die Bytes übernehmen
Dateien, die nicht in den Speicher passen, werden mit document_stream() in Blöcken gelesen oder mit files_streaming() direkt durchgereicht.
10. Im Namen eines anderen Benutzers arbeiten
Ein Importer meldet sich in der Regel mit einem technischen Konto an, während die abgelegten Objekte
den zuständigen Benutzer als Ersteller tragen sollen, auch in der Objekthistorie. impersonate()
gibt dazu eine zweite ECM-Instanz auf demselben Pool zurück, die jeder Anfrage den Kontextwechsel
mitgibt, ohne zweite Anmeldung und ohne zweite Verbindung.
Der ausführende Benutzer benötigt dafür die Systemrolle Kontextwechsel.
Der Zielbenutzer kann über username, user_guid oder user_id angegeben werden — genau eines davon.
Ein str ist immer ein Benutzername, niemals eine GUID.
-
Sync
-
Async
# Als Context-Manager
with ecm.impersonate("john") as ecm_john:
folder = ecm_john.dms.insert_and_get(InvoiceFolder(Title="Test"))
# Oder direkt ohne with-Block
ecm_john = ecm.impersonate("john")
folder = ecm_john.dms.insert_and_get(InvoiceFolder(Title="Test"))
# Oder über GUID bzw. numerische ID des Benutzers
ecm_john = ecm.impersonate(user_guid="8A1D1F2E4C7B4A9E8F0D3C5B7A9E1D2F")
ecm_john = ecm.impersonate(user_id=42)
# Als Context-Manager
async with ecm.impersonate("john") as ecm_john:
folder = await ecm_john.dms.insert_and_get(InvoiceFolder(Title="Test"))
# Oder direkt ohne with-Block
ecm_john = ecm.impersonate("john")
folder = await ecm_john.dms.insert_and_get(InvoiceFolder(Title="Test"))
Als Context-Manager bleibt der Wechsel auf den Block begrenzt, womit im Code sichtbar bleibt, welche Operationen unter welchem Benutzer laufen. Vollständige Beschreibung: impersonate() in der API-Referenz.
11. Wenn etwas schiefgeht
Jeder Fehler, den der Server meldet, kommt als Exception aus einer kleinen Hierarchie an.
ECMException ist die Basisklasse, wer sie fängt, fängt alles, was die ECM-API auslöst:
| Exception | Wird ausgelöst, wenn |
|---|---|
|
das Objekt, der Objekttyp, der Schrank, das Register oder die Datei nicht existiert, etwa |
|
dem angemeldeten Benutzer die Rechte für die Operation fehlen |
|
die Operation nicht zum Zustand des Objekts passt, etwa die aktive Variante eines Dokuments ohne Varianten |
|
eine erforderliche Kennung fehlt, etwa ein Dokument ohne Ordner oder Register |
|
alles Übrige, was der Server meldet, darunter nicht gefüllte Pflichtfelder und unzulässige Feldwerte |
Zwei Fehler entstehen clientseitig, noch vor dem Senden: ein ValueError bei einem fehlenden
Pflichtfeld, einem geschriebenen schreibgeschützten Feld oder einem unentschiedenen Leeren eines
Tabellenfelds, und ein TypeError bei Dateien an einem Modell, das kein Dokument ist.
from ecmind_blue_client.ecm import ECMException, ECMNotFoundException
try:
folder = ecm.dms.get(InvoiceFolder, 4711)
except ECMNotFoundException:
print("kein Ordner mit der ID 4711")
except ECMException as error:
print("der Server hat die Anfrage abgelehnt:", error)
In der async-Variante wartet derselbe Block mit await auf ecm.dms.get(…), sonst ändert sich
nichts.
Die Meldung enthält den Text des Servers samt einer Beschreibung des dahinterliegenden Result-Codes, sie gehört also unverändert ins Protokoll und nicht durch eine eigene Meldung ersetzt.
11.1. Sehen, was der Client tut
Die Bibliothek protokolliert über das Standardmodul logging, mit einem Logger pro Modul unterhalb
von ecmind_blue_client, und bleibt still, solange die Anwendung kein Logging konfiguriert. Ein
höherer Level zeigt Verbindungs- und Pool-Aktivität, was in der Regel genügt, um einen hängenden
Server von einem hängenden Skript zu unterscheiden:
import logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("ecmind_blue_client").setLevel(logging.DEBUG)
Wenn überhaupt nichts funktioniert, gehört die Prüfung vor den Code: Servererreichbarkeit prüfen testet jeden konfigurierten Server auf Erreichbarkeit, Anmeldung und Version.
12. Das vollständige Skript
Die Schritte von oben in einer lauffähigen main.py. Die Zugangsdaten kommen aus der Umgebung, die
Zeitzone des Servers wird vor dem ersten Job gesetzt, und jeder Serverfehler wird an einer Stelle
gefangen:
-
Sync
-
Async
import logging
import os
from datetime import date
from ecmind_blue_client import set_server_timezone
from ecmind_blue_client.ecm import ECM, ECMException
from ecmind_blue_client.pool import SyncPoolClient
from ecmind_blue_client.rpc import JobRequestFileFromPath
from models.Invoices import InvoiceDocument, InvoiceFolder, InvoiceRegister
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("invoice-import")
def main() -> None:
set_server_timezone("Europe/Zurich")
client = SyncPoolClient(
servers=os.environ["ECM_SERVERS"],
username=os.environ["ECM_USERNAME"],
password=os.environ["ECM_PASSWORD"],
)
ecm = ECM(client)
try:
# 1. SQL: eine Auswertungszahl, die die Objekt-API nicht ausdrücken kann
result = ecm.db.select("SELECT COUNT(*) AS anzahl FROM benutzer WHERE aktiv = %d", 1)
log.info("aktive Benutzer: %s", result.rows[0].typed("anzahl", int))
# 2. Ordner: anlegen oder aktualisieren, falls er bereits existiert
folder = (
ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
.search(InvoiceFolder.Title == "Rechnung 2024")
.execute_and_get()
)
log.info("Ordner %s", folder.system.id)
# 3. Register in diesem Ordner, Dokument samt Datei in diesem Register
register = ecm.dms.insert_and_get(InvoiceRegister(Name="März"), folder_id=folder)
document = ecm.dms.insert_and_get(
InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
[JobRequestFileFromPath("rechnung.pdf")],
folder_id=folder,
register_id=register,
)
log.info("Dokument %s mit %s Datei(en)", document.system.id, len(ecm.dms.files(document)))
# 4. Lesen: alle Ordner ab 2024, neueste zuerst
for hit in (
ecm.dms.select(InvoiceFolder)
.where(InvoiceFolder.Year >= 2024)
.order_by(InvoiceFolder.Year.DESC)
.stream()
):
log.info("%s %s %s", hit.system.id, hit.Title, hit.Year)
# 5. Ein Feld ändern, nur dieses Feld geht an den Server
document.Title = "Rechnung 4711 (geprüft)"
ecm.dms.update(document)
# 6. Im Namen von john ablegen, damit das Objekt ihn als Ersteller trägt
# (benötigt die Systemrolle Kontextwechsel)
with ecm.impersonate("john") as ecm_john:
filed = ecm_john.dms.insert_and_get(InvoiceFolder(Title="Rechnung 2024 (john)", Year=2024))
log.info("abgelegt als john: %s", filed.system.id)
except ECMException as error:
log.error("der Server hat die Anfrage abgelehnt: %s", error)
finally:
client.close()
if __name__ == "__main__":
main()
import asyncio
import logging
import os
from datetime import date
from ecmind_blue_client import set_server_timezone
from ecmind_blue_client.ecm import ECM, ECMException
from ecmind_blue_client.pool import AsyncPoolClient
from ecmind_blue_client.rpc import JobRequestFileFromPath
from models.Invoices import InvoiceDocument, InvoiceFolder, InvoiceRegister
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("invoice-import")
async def main() -> None:
set_server_timezone("Europe/Zurich")
client = AsyncPoolClient(
servers=os.environ["ECM_SERVERS"],
username=os.environ["ECM_USERNAME"],
password=os.environ["ECM_PASSWORD"],
)
ecm = ECM(client)
try:
# 1. SQL: eine Auswertungszahl, die die Objekt-API nicht ausdrücken kann
result = await ecm.db.select("SELECT COUNT(*) AS anzahl FROM benutzer WHERE aktiv = %d", 1)
log.info("aktive Benutzer: %s", result.rows[0].typed("anzahl", int))
# 2. Ordner: anlegen oder aktualisieren, falls er bereits existiert
folder = await (
ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
.search(InvoiceFolder.Title == "Rechnung 2024")
.execute_and_get()
)
log.info("Ordner %s", folder.system.id)
# 3. Register in diesem Ordner, Dokument samt Datei in diesem Register
register = await ecm.dms.insert_and_get(InvoiceRegister(Name="März"), folder_id=folder)
document = await ecm.dms.insert_and_get(
InvoiceDocument(Title="Rechnung 4711", InvoiceDate=date(2024, 3, 1)),
[JobRequestFileFromPath("rechnung.pdf")],
folder_id=folder,
register_id=register,
)
log.info("Dokument %s mit %s Datei(en)", document.system.id, len(await ecm.dms.files(document)))
# 4. Lesen: alle Ordner ab 2024, neueste zuerst
async for hit in (
ecm.dms.select(InvoiceFolder)
.where(InvoiceFolder.Year >= 2024)
.order_by(InvoiceFolder.Year.DESC)
.stream()
):
log.info("%s %s %s", hit.system.id, hit.Title, hit.Year)
# 5. Ein Feld ändern, nur dieses Feld geht an den Server
document.Title = "Rechnung 4711 (geprüft)"
await ecm.dms.update(document)
# 6. Im Namen von john ablegen, damit das Objekt ihn als Ersteller trägt
# (benötigt die Systemrolle Kontextwechsel)
async with ecm.impersonate("john") as ecm_john:
filed = await ecm_john.dms.insert_and_get(
InvoiceFolder(Title="Rechnung 2024 (john)", Year=2024)
)
log.info("abgelegt als john: %s", filed.system.id)
except ECMException as error:
log.error("der Server hat die Anfrage abgelehnt: %s", error)
finally:
await client.aclose()
if __name__ == "__main__":
asyncio.run(main())
export ECM_SERVERS=enaio.example.com:4000:1 ECM_USERNAME=admin ECM_PASSWORD=secret
uv run main.py
Das Repository liefert beide Varianten lauffähig mit, als examples/quickstart_sync.py und
examples/quickstart_async.py.
13. Nächste Schritte
-
ecm-generate-models generiert Modelle für alle Schränke, inklusive Tabellenfelder und Listenfelder
-
Servererreichbarkeit prüfen prüft Host, Port und Zugangsdaten vor dem ersten Job
-
Batch-Import macht aus diesen Schritten einen vollständigen Importer: CSV als Eingabe, idempotente Upserts, Fehlerbehandlung pro Datensatz
-
FastAPI-Integration baut einen vollständigen REST-Service auf dem async-Client
-
KeepAlive im Pool hält unbenutzte Verbindungen in langlaufenden Prozessen offen
-
API-Referenz dokumentiert alle Namespaces und Methoden