ECM-Modell
Das ECM-Modell ist das zentrale Konzept von ecmind-blue-client. Es bietet eine typisierte, ORM-artige Schnittstelle für ECM-Objekte — Ordner, Register und Dokumente.
Jedes Objekt, das von ecm.dms.select(), ecm.dms.get(), ecm.dms.insert_and_get() oder ecm.dms.update_and_get() zurückgegeben wird, ist eine Instanz einer Modellklasse. Modellinstanzen enthalten sowohl Indexfelddaten (benutzerdefinierte Felder) als auch Systemmetadaten (immer über obj.system).
1. Modellklassen
Eine Modellklasse wird durch Ableitung von einer der drei Basisklassen definiert. Die Felder werden als typisierte Klassenannotationen deklariert:
from ecmind_blue_client.ecm.model import ECMFolderModel, ECMRegisterModel, ECMDocumentModel, ECMField, ECMTableField, ECMTableRowModel
class RechnungsZeile(ECMTableRowModel):
Betrag: ECMField[float]
Beschreibung: ECMField[str]
class RechnungsOrdner(ECMFolderModel):
_internal_name_ = "InvoiceFolder" # interner Name des Objekttyps auf dem Server
Titel: ECMField[str]
Jahr: ECMField[int]
Positionen: ECMTableField[RechnungsZeile]
class RechnungsRegister(ECMRegisterModel):
_internal_name_ = "InvoiceRegister"
Name: ECMField[str]
class RechnungsDokument(ECMDocumentModel):
_internal_name_ = "InvoiceDocument"
Betreff: ECMField[str]
Betrag: ECMField[float]
| Basisklasse | Verwendung |
|---|---|
|
Ordner-Objekttypen |
|
Register-Objekttypen (Unterordner) |
|
Dokument-Objekttypen |
1.1. Dynamische Modelle
Wenn der Objekttyp erst zur Laufzeit bekannt ist, können statt einer Klassendefinition Factory-Funktionen verwendet werden:
from ecmind_blue_client.ecm.model import make_folder_model, make_register_model, make_document_model
RechnungsOrdner = make_folder_model("InvoiceFolder")
Dynamische Modelle unterstützen dieselbe Query-API. Nicht deklarierte Felder werden über obj["internalName"] abgerufen, siehe Feldzugriff per Subscript.
2. ECMField
ECMField[T] ist der Descriptor für typisierte Indexfelder. Der Typparameter T legt den Python-Typ des Feldwertes fest (str, int, float, datetime, date, time, bool oder ein generiertes Katalog-Enum).
Felder mit Listenkatalog werden mit einer generierten (str, Enum)-Klasse typisiert (siehe Modellgenerator). Wird einem solchen Feld ein Enum-Member zugewiesen, schreibt der Client den zugrunde liegenden Katalogwert — das Enum wird beim Speichern auf seinen .value reduziert, sodass ordner.Status = InvoiceFolder_StatusEnum.PAID als Paid gesendet wird.
Auf Klassenebene wirkt ECMField als Condition-Builder:
RechnungsOrdner.Jahr == 2024 # ECMCondition
RechnungsOrdner.Jahr >= 2020 # ECMCondition
RechnungsOrdner.Titel.in_("A", "B") # ECMCondition
RechnungsOrdner.Titel.in_(["A", "B"]) # dieselbe Bedingung, Werte als ein Iterable
RechnungsOrdner.Jahr.DESC # ECMSortOrder für order_by()
.in_() und .not_in() nehmen die Werte entweder als einzelne Argumente oder als ein einzelnes
Iterable (Liste, Tuple, Set, Generator, range). Ein Container, der zwischen den Werten stehen
bleibt, wirft TypeError, eine leere Wertemenge wirft ValueError, weil der Server die Bedingung
sonst weglässt und alles zurückgibt. Details unter select().
Auf Instanzebene gibt er den gespeicherten Wert zurück:
ordner = ecm.dms.get(RechnungsOrdner, 12345)
print(ordner.Jahr) # int | None
2.1. Pflichtfelder
Felder können als Pflichtfeld deklariert werden. insert() und update() prüfen diese standardmäßig clientseitig:
class RechnungsOrdner(ECMFolderModel):
_internal_name_ = "InvoiceFolder"
Titel: ECMField[str] = ECMField(mandatory=True)
Jahr: ECMField[int]
2.2. Read-only-Felder
Felder können schreibgeschützt sein. Der Parameter read_only von ECMField wird beim Speichern (insert(), update(), upsert()) clientseitig durchgesetzt — verglichen wird gegen den vom Server geladenen Wert:
| Wert | Bedeutung |
|---|---|
|
Immer schreibgeschützt — der Server besitzt den Wert. Wird das Feld bei einer Neuanlage gesetzt oder bei einem bestehenden Objekt geändert, schlägt das Speichern fehl. Ein als |
|
Nur bei Neuanlage beschreibbar (solange das Objekt keine ID hat). Sobald das Objekt gespeichert wurde ( |
|
Beschreibbar, bis das Dokument archiviert ist (nicht leeres |
class Rechnung(ECMDocumentModel):
_internal_name_ = "Invoice"
Belegnummer: ECMField[str] = ECMField(str, mandatory=True, read_only="init")
SystemId: ECMField[str] = ECMField(str, default=None, read_only="always")
ECMField(read_only=…) wird vom Modellgenerator automatisch aus den Definitions-Flags (readonly / readonly_after_initialization / readonly_after_archiving) abgeleitet; Vorrang always > init > arch.
3. ECMTableField
ECMTableField[RowT] enthält mehrreihige Tabellenfelder. Die Zeilenklasse muss ECMTableRowModel ableiten und ihre Spalten als ECMField-Annotationen deklarieren.
Eine Abfrage liefert ein Tabellenfeld nur, wenn es angefordert wird. select() enthält sie standardmäßig, .fields() entfernt aber die nicht genannten, und select_lol() enthält nie eines implizit. Das Klassenattribut an .fields() übergeben oder .with_table_fields() aufrufen. Siehe Tabellenfelder anfordern.
|
for zeile in ordner.Positionen:
print(zeile.Betrag, zeile.Beschreibung)
Jede Zeile stellt folgende Attribute bereit:
| Attribut | Beschreibung |
|---|---|
|
Interne Zeilenkennung, die vom Server vergeben wird. |
|
|
|
Gibt |
3.1. Zeilen hinzufügen
Eine Zeilenklasse nimmt ihre Spaltenwerte als Schlüsselwortargumente entgegen, genau wie eine
Modellklasse. Die gebaute Zeile geht an das Tabellenfeld, entweder über den Modellkonstruktor, über
eine Zuweisung der Liste oder per append:
ordner = RechnungsOrdner(
Titel="Rechnung 2024",
Positionen=[
RechnungsZeile(Beschreibung="A-100", Betrag=12.5),
RechnungsZeile(Beschreibung="A-200", Betrag=7.0),
],
)
ordner.Positionen.append(RechnungsZeile(Beschreibung="A-300", Betrag=3.0))
zeile = RechnungsZeile()
zeile.Beschreibung = "A-400"
ordner.Positionen.append(zeile)
Die Schlüsselwortargumente werden über die deklarierten ECMField-Deskriptoren aufgelöst, der Wert
landet also unter dem internen ECM-Spaltennamen, auch wenn das Attribut anders heisst
(ECMField(str, internal_name="ArticleNo")). Nicht deklarierte Keys gehen unverändert durch. Eine so
gebaute Zeile hat keine row_id, die vergibt der Server beim Speichern, und gilt vollständig als
geändert.
Ein einfaches Dict funktioniert genauso: Positionen=[{"Beschreibung": "A-100", "Betrag": 12.5}].
3.2. Tabellenspalten abfragen
Eine Bedingung auf eine Tabellenfeld-Spalte wird über den Spaltenzugriff am Tabellenfeld auf
Klassenebene gebildet. select(…).where(Invoice.Positions.ArticleNo == "A-100") erzeugt eine
<TableCondition> / <TableColumn>, sodass der Server auf die Untertabellen-Spalte filtert, anstatt
ein unbekanntes Objektfeld abzulehnen. Mit der Server-Zeilennummer als Index lässt sich die Bedingung
auf eine Zeile beschränken (Invoice.Positions[3].Quantity == 5; expliziter Alias .row(3)). Alle
Vergleichs- und Mengenoperatoren sind anwendbar. Vollständige Beispiele und das erzeugte XML: siehe
select().
4. Feldzugriff per Subscript
Auf Instanzebene ist obj["Feldname"] gleichwertig zum Attributzugriff, nützlich für dynamische
Modelle, generische Werkzeuge und Templates, in denen der Feldname erst zur Laufzeit feststeht.
Gelesen wird der Wert, den auch das Attribut liefert: für ein Tabellenfeld die ECMTableList mit
den Zeilen, für alles andere der Skalarwert.
dokument = ecm.dms.get(Posteingang, 12345)
dokument["Betreff"] # wie dokument.Betreff
dokument["Verlauf"] # ECMTableList, wie dokument.Verlauf
dokument["Verlauf"][0]["Datum"] # Spaltenwert einer Zeile
Sowohl der Attributname als auch der ECM-interne Feldname lösen auf, ebenso bei Tabellenzeilen.
Ein Name, der weder deklariert noch geladen ist, wirft einen KeyError mit dem Namen. Ein
deklariertes Feld, das die Abfrage nicht zurückgegeben hat (etwa nach fields()), bleibt dagegen
None, genau wie beim Attributzugriff. In Jinja wird aus dem KeyError ein benanntes Undefined,
statt dass eine Schleife über ein stilles None läuft:
dokument["Betreef"] # KeyError: 'Betreef'
"Betreff" in dokument # True, Prüfung ohne Ausnahme
Bei dynamischen Modellen gibt es keine Deklarationen: Dort ist ein Feld, das der Server nicht
zurückgegeben hat, nicht von einem Tippfehler zu unterscheiden und wirft ebenfalls KeyError. Der
in-Test ist dafür die Prüfung ohne Ausnahme.
Geschrieben wird symmetrisch: Ein Tabellenfeldname nimmt eine Liste aus Dicts oder Zeileninstanzen
entgegen und durchläuft dieselbe Umwandlung wie die Attributzuweisung, inklusive der
touched-Markierung. Nicht deklarierte Namen werden weiterhin als Skalar angenommen, denn so setzen
dynamische Modelle ihre Felder.
dokument["Betreff"] = "Einsprache"
dokument["Verlauf"] = [{"Datum": "2026-01-31", "Text": "Eingang"}] # typisierte Zeilen
5. system-Eigenschaften
Jede Modellinstanz besitzt ein system-Attribut, das alle serverseitig befüllten Metadaten enthält. Indexfelder (eigene ECMField-Deklarationen) und Systemeigenschaften sind strikt getrennt.
5.1. system-Felder in Abfragen (Klassen-Zugriff)
Das system-Attribut verhält sich je nach Zugriffsebene unterschiedlich:
-
Instanz-Zugriff (
obj.system.id) liefert den geladenen Wert (z. B.int). -
Klassen-Zugriff (
Modell.system.id) liefert einECMField, das wie ein Indexfeld inwhere(),order_by()undfields()verwendet werden kann. In Sortier- und Ergebnisfeldern wird dassystem="1"-Attribut automatisch gesetzt.
So lässt sich derselbe punktierte Pfad in einer Abfrage einsetzen. Typisches Beispiel — alle Dokumente in einem bekannten Ordner über eine schrankübergreifende (Cross-Type-)Bedingung auf die Ordner-ID:
docs = (
ecm.dms.select(PostDoc)
.where(Post.system.id == folder.system.id) # links Klasse (ECMField), rechts Instanzwert (int)
.limit(1)
.execute()
)
Die erzeugte Bedingung ist an den Objekttyp des Modells gebunden, auf dem sie aufgerufen wird (Post), und wird vom HOL-Builder in ein eigenes ConditionObject einsortiert — identisch zu Post["OBJECT_ID"], inklusive automatischem system="1"-Attribut. Es werden nur Systemfelder mit einer einzelnen abfragbaren Spalte als ECMField angeboten; aggregierte Werte ohne Backing-Feld (system.rights, system.name, system.is_modified, …) sind ausschließlich auf Instanzen verfügbar.
Abfragbar sind: die unter Immer verfügbar gelisteten Felder mit Einzelspalten-Backing (id, owner_guid, creator, creation_date, last_modifier, last_modified, creation_time, deleted_at), die typabhängigen Felder (folder_id, parent_register_id, register_id, register_type_id, system_id, foreign_id, lock_user_id, archivist, archiving_date) sowie die abfragbare Teilmenge von system.base_params (creator, creation_date, modifier, modified_date, links_count, locked_user_id) und system.file_properties (count, size). Felder ohne abfragbare Serverspalte (z. B. system.base_params.version, system.file_properties.extension) bleiben rein instanzseitig.
5.1.1. Generischer Item-Zugriff für beliebige Systemfelder
Der .system-Namespace bietet nur die kuratierte, typisierte Teilmenge an. Für jedes Server-Systemfeld — auch solche ohne .system-Eigenschaft (OBJECT_FLAGS, OBJECT_RETENTION, OBJECT_MAIN, …) — und für dynamische Modelle ohne deklarierte Felder steht der generische Item-Zugriff auf der Modellklasse zur Verfügung:
ecm.dms.select(PostDoc).where(Post["OBJECT_ID"] == folder.system.id) # gleichbedeutend mit Post.system.id
ecm.dms.select(PostDoc).where(Post["OBJECT_FLAGS"] == 0) # Systemfeld ohne .system-Eigenschaft
Modell["NAME"] liefert ein ECMField, das an den Objekttyp gebunden ist; ist NAME ein Member des SystemFields-Enums (z. B. OBJECT_ID), wird beim Bauen automatisch system="1" gesetzt. Der Item-Zugriff ist untypisiert (ECMField[str], keine IDE-Vervollständigung); Modell.system.<feld> ist typisiert und entdeckbar, aber auf die kuratierte Teilmenge beschränkt. Beide erzeugen dieselbe Bedingung. Derselbe Item-Zugriff funktioniert auch für nicht deklarierte Indexfelder (Modell["EigenesFeld"]).
5.2. Immer verfügbar
Die folgenden Eigenschaften sind immer gefüllt, unabhängig von den Flags des Abfrage- oder Abrufaufrufs:
| Eigenschaft | Typ | Beschreibung |
|---|---|---|
|
|
Numerische ID des Objekts auf dem Server. |
|
|
Anzeigename des Objekts, wie er auf dem Server gespeichert ist. Entspricht in der Regel dem Wert des Schlüsselfeldes. |
|
|
|
|
|
|
|
|
Namen der Tabellenfelder, die der Aufrufer absichtlich geleert hat — zugewiesen, übergeben oder verändert ( |
|
|
Die seit dem Laden geänderten internen Feldnamen, abgebildet auf ihren aktuellen Wert. |
|
|
Die Tabellenfeldnamen, bei denen Zeilen hinzugefügt, entfernt oder geändert wurden, abgebildet auf ihre aktuelle Zeilenliste. |
|
|
Gibt |
5.3. Je nach Objekttyp verfügbar
Einige Systemeigenschaften sind nur bei bestimmten Modelltypen vorhanden:
| Eigenschaft | Typ | Verfügbar bei | Beschreibung |
|---|---|---|---|
|
|
|
ID des übergeordneten Ordners ( |
|
|
|
ID des direkt übergeordneten Registers ( |
|
|
|
ID des übergeordneten Registers ( |
|
|
|
Typ-ID des übergeordneten Registers ( |
|
|
|
ID des externen Archivsystems ( |
|
|
|
Referenz auf das Dokument im externen Archivsystem ( |
|
|
|
Numerische ID des Benutzers, der das Dokument aktuell gesperrt hält ( |
|
|
|
|
|
|
|
Roher Archivierungsstatus-Code ( |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Die Archivierungs- und Sperr-Eigenschaften (
Die Suchflag-Eigenschaften |
5.4. Optional — auf Anfrage geladen
Die folgenden Eigenschaften sind standardmäßig None und müssen über Flags bei select(), get(), insert_and_get() oder update_and_get() explizit angefordert werden.
5.4.1. system.rights
| Attribut | Typ | Beschreibung |
|---|---|---|
|
|
Darf untergeordnete Objekte anlegen (Register oder Dokumente in einem Ordner; Dokumente in einem Register). |
|
|
Darf die Indexfelder des Objekts ändern. |
|
|
Darf die Datei des Dokuments lesen. |
|
|
Darf die Datei des Dokuments ändern. |
|
|
Darf das Objekt löschen. |
Jedes Attribut ist genau dann True, wenn der Server das Recht gewährt; ein nicht gewährtes Recht ist False. Zusammen mit den Rechten werden auch die Einfüge-Kontingente angefordert, damit insert einen echten Wert trägt.
Liefert der Server ein <Rights>-Element, das er gar nicht ausgewertet hat (alle Attribute -1, wenn die Abfrage keine Rechte angefordert hat), bleibt system.rights unbefüllt und der Zugriff wirft wie gewohnt einen ValueError.
ordner = ecm.dms.get(RechnungsOrdner, 12345, rights=True)
if ordner.system.rights.edit_metadata:
print("Bearbeitung erlaubt")
5.4.2. system.base_params
| Attribut | Typ | Beschreibung |
|---|---|---|
|
|
Benutzername des Erstellers. |
|
|
Erstellungszeitpunkt. |
|
|
Aktueller Eigentümer des Objekts. |
|
|
Benutzername des letzten Bearbeiters. |
|
|
Zeitstempel der letzten Änderung. |
|
|
Anzahl der Verknüpfungen auf dieses Objekt. |
|
|
Anzahl der Textnotizen (Bemerkungen) am Objekt. |
|
|
Sperrzustand des Objekts: |
|
|
Numerische ID des sperrenden Benutzers, oder |
|
|
Anzahl der PDF-Annotationen am Objekt. |
|
|
Versionsnummer des Objekts. |
|
|
Anzeigetext des Archivierungszustands, oder |
|
|
Numerischer Wert des Archivierungszustands (aus dem |
ordner = ecm.dms.get(RechnungsOrdner, 12345, base_params=True)
print(f"Erstellt von {ordner.system.base_params.creator} am {ordner.system.base_params.creation_date}")
5.4.3. system.file_properties
Befüllt bei file_properties=True (get()) oder .file_properties() (select()). Nur für ECMDocumentModel relevant. Typ: ECMModelFileProperties.
| Attribut | Typ | Beschreibung |
|---|---|---|
|
|
Anzahl der Dateien (Primär- und Sekundärdateien). |
|
|
Gesamtdateigröße in Bytes. |
|
|
Dateiendung (z. B. |
|
|
MIME-Typ (z. B. |
|
|
MIME-Gruppe (z. B. |
|
|
ID des Dateitypicons. |
|
|
Seitenanzahl, sofern bekannt. |
dok = ecm.dms.get(RechnungsDokument, 42, file_properties=True)
fp = dok.system.file_properties
print(f"{fp.extension}, {fp.size} Bytes, {fp.documentpagecount} Seiten")
5.4.4. system.variants
Befüllt bei variants=True (get()) oder .variants() (select()). Nur für ECMDocumentModel mit aktiviertem W-Modul relevant. Typ: list[ECMModelDocumentVariant].
Jeder Eintrag enthält:
| Attribut | Typ | Beschreibung |
|---|---|---|
|
|
Dokument-ID dieser Variante. |
|
|
Versionsbezeichnung (z. B. |
|
|
|
|
|
ID der übergeordneten Variante, oder |
|
|
Die aus dieser Variante abgezweigten Kindvarianten. |
|
|
Verschachtelungstiefe im Variantenbaum: |
walk() gibt einen Knoten und rekursiv alle darunterliegenden zurück, tiefensuchend — praktisch, um den Baum ohne eigene Rekursion zu durchsuchen:
for root in dok.system.variants:
for variante in root.walk():
print(" " * variante.level, variante.doc_ver)
Ohne typisiertes Laden liefert variants() dieselben Knoten allein anhand einer Objekt-ID.
5.4.5. system.active_variant
Die aktive Variante aus dem geladenen Variantenbaum, auf jeder Ebene gesucht. Abgeleitet aus system.variants, also ohne weiteren Serveraufruf — in einer Abfrage mit .variants() kostet der Zugriff pro Treffer nichts. Typ: ECMModelDocumentVariant | None; None, wenn das Dokument keine Varianten hat.
for dok in ecm.dms.select(RechnungsDokument).variants().limit(100).stream():
aktiv = dok.system.active_variant
print(dok.system.id, aktiv.doc_ver if aktiv else "keine Varianten")
Wurde variants nicht angefordert, wirft die Property wie system.variants einen ValueError — nachladen kann das Modell nicht, es kennt keine Verbindung. Für ein nicht geladenes Dokument gibt es active_variant() im Namespace ecm.dms.
dok = ecm.dms.get(RechnungsDokument, 42, variants=True)
for v in dok.system.variants:
print(v.doc_ver, "✓" if v.is_active else "")
6. Änderungsverfolgung
Modellinstanzen verfolgen Feldänderungen automatisch. Das Zuweisen eines neuen Wertes an ein ECMField markiert das Feld als geändert:
ordner = ecm.dms.get(RechnungsOrdner, 12345)
ordner.Titel = "Aktualisierter Titel"
print(ordner.system.is_modified) # True
print(ordner.system.modified_fields) # {"Titel": "Neuer Titel"}
print(ordner.system.is_field_modified("Jahr")) # False
update() liest system.is_modified, um zu entscheiden, ob der Serveraufruf übersprungen werden kann. Wurden keine Felder geändert und werden keine Dateien übergeben, wird der Aufruf stillschweigend ausgelassen (außer bei force=True).
Das Entfernen einer Zeile aus einem ECMTableField setzt system.has_removed_table_rows = True, wodurch update() REPLACETABLEFIELDS=1 zur Anfrage hinzufügt.
Die Zeilenliste eines Tabellenfelds ist eine ECMTableList und merkt sich zusätzlich, dass der Aufrufer sie angefasst hat. Eine Zuweisung, die Übergabe im Konstruktor und jeder mutierende Aufruf (append, clear, del, …) markieren sie; Listen aus einer Serverantwort oder aus den Klassen-Defaults bleiben unmarkiert. Eine markierte Liste, die leer endet, ist ein absichtliches Leeren — genau so unterscheidet upsert() Positionen=[] von einem Tabellenfeld, das niemand benannt hat:
folder = RechnungsOrdner(Titel="Rechnung 2024")
print(folder.Positionen.touched) # False — der implizite Default
print(folder.system.explicitly_emptied_table_fields) # []
folder.Positionen = [] # benennen ist Absicht
print(folder.system.explicitly_emptied_table_fields) # ["Positionen"]
7. Siehe auch
-
Workflow-Modell — das typisierte Modell für Workflow-Eingangsvariablen
-
select() — Query-Builder mit
.rights(),.base_params(),.file_properties(),.variants(),.remarks() -
get() — Einzelnes Objekt per ID laden mit optionalen Flag-Parametern
-
insert() / insert_and_get() — Neue Objekte anlegen
-
update() / update_and_get() — Änderungen auf dem Server speichern