ECM Model
The ECM model is the central concept of ecmind-blue-client. It provides a typed, ORM-style interface for ECM objects — folders, registers, and documents.
Every object returned by ecm.dms.select(), ecm.dms.get(), ecm.dms.insert_and_get(), or ecm.dms.update_and_get() is an instance of a model class. Model instances carry both index field values (your custom fields) and system metadata (always via obj.system).
1. Model classes
Define a model class by subclassing one of the three base classes and declaring typed fields as class annotations:
from ecmind_blue_client.ecm.model import ECMFolderModel, ECMRegisterModel, ECMDocumentModel, ECMField, ECMTableField, ECMTableRowModel
class InvoiceRow(ECMTableRowModel):
Amount: ECMField[float]
Description: ECMField[str]
class InvoiceFolder(ECMFolderModel):
_internal_name_ = "InvoiceFolder" # server-side internal name of the object type
Title: ECMField[str]
Year: ECMField[int]
Positions: ECMTableField[InvoiceRow]
class InvoiceRegister(ECMRegisterModel):
_internal_name_ = "InvoiceRegister"
Name: ECMField[str]
class InvoiceDocument(ECMDocumentModel):
_internal_name_ = "InvoiceDocument"
Subject: ECMField[str]
Amount: ECMField[float]
| Base class | Use for |
|---|---|
|
Folder object types |
|
Register (sub-folder) object types |
|
Document object types |
1.1. Dynamic models
When the object type is only known at runtime, use the factory functions instead of a class statement:
from ecmind_blue_client.ecm.model import make_folder_model, make_register_model, make_document_model
InvoiceFolder = make_folder_model("InvoiceFolder")
Dynamic models support the same query API. Undeclared fields are accessed via obj["internalName"], see Field access by subscript.
2. ECMField
ECMField[T] is the descriptor for typed index fields. The type parameter T determines the Python type of the field value (str, int, float, datetime, date, time, bool, or a generated catalog enum).
Fields backed by a list catalog are typed with a generated (str, Enum) class (see the model generator). Assigning a member writes its underlying catalog value — the enum is unwrapped to its .value on save, so folder.Status = InvoiceFolder_StatusEnum.PAID is sent as Paid.
At class level, ECMField acts as a condition builder:
InvoiceFolder.Year == 2024 # ECMCondition
InvoiceFolder.Year >= 2020 # ECMCondition
InvoiceFolder.Title.in_("A", "B") # ECMCondition
InvoiceFolder.Title.in_(["A", "B"]) # the same condition, values as one iterable
InvoiceFolder.Year.DESC # ECMSortOrder for order_by()
.in_() and .not_in() accept the values either as separate arguments or as a single iterable
(list, tuple, set, generator, range). A container left among the values raises TypeError, and
an empty value set raises ValueError, because the server would drop the condition and return
everything. See select() for the details.
At instance level, it returns the stored value:
folder = ecm.dms.get(InvoiceFolder, 12345)
print(folder.Year) # int | None
2.1. Mandatory fields
class InvoiceFolder(ECMFolderModel):
_internal_name_ = "InvoiceFolder"
Title: ECMField[str] = ECMField(mandatory=True)
Year: ECMField[int]
2.2. Read-only fields
Fields can be write-protected. The read_only parameter of ECMField is enforced client-side on save (insert(), update(), upsert()) by comparing against the value loaded from the server:
| Value | Meaning |
|---|---|
|
Always read-only — the server owns the value. Setting it on a new object or changing it on an existing one fails. A field marked |
|
Writable only at creation (while the object has no ID). Once the object has been persisted ( |
|
Writable until the document is archived (non-empty |
class Invoice(ECMDocumentModel):
_internal_name_ = "Invoice"
InvoiceNumber: ECMField[str] = ECMField(str, mandatory=True, read_only="init")
SystemId: ECMField[str] = ECMField(str, default=None, read_only="always")
ECMField(read_only=…) is derived automatically by the model generator from the definition flags (readonly / readonly_after_initialization / readonly_after_archiving); precedence always > init > arch.
3. ECMTableField
ECMTableField[RowT] holds multi-row table fields. The row class must subclass ECMTableRowModel and declare its columns as ECMField annotations.
A query only returns a table field when it is requested. select() includes them by default, but .fields() drops the ones it does not list, and select_lol() never includes one implicitly. Pass the class attribute to .fields(), or call .with_table_fields(). See Requesting table fields.
|
for row in folder.Positions:
print(row.Amount, row.Description)
Each row exposes:
| Attribute | Description |
|---|---|
|
Internal row identifier assigned by the server. |
|
|
|
Returns |
3.1. Adding rows
A row class takes its column values as keyword arguments, exactly like a model class. Build a row and hand it to the table field, either through the model constructor, by assigning the list, or by appending to it:
folder = InvoiceFolder(
Title="Invoice 2024",
Positions=[
InvoiceRow(ArticleNo="A-100", Amount=12.5),
InvoiceRow(ArticleNo="A-200", Amount=7.0),
],
)
folder.Positions.append(InvoiceRow(ArticleNo="A-300", Amount=3.0))
row = InvoiceRow()
row.ArticleNo = "A-400"
folder.Positions.append(row)
Keyword arguments are resolved through the declared ECMField descriptors, so the value is stored
under the ECM internal column name even when the attribute is named differently
(ECMField(str, internal_name="ArticleNo")). Undeclared keys are passed through unchanged. A row
built this way has no row_id — the server assigns one when the object is saved — and counts as
modified in full.
A plain dict works as well and is equivalent: Positions=[{"ArticleNo": "A-100", "Amount": 12.5}].
3.2. Querying table columns
A condition on a table-field column is built by accessing the column on the table field at class
level. select(…).where(Invoice.Positions.ArticleNo == "A-100") emits a <TableCondition> /
<TableColumn> so the server filters on the sub-table column instead of rejecting an unknown object
field. Restrict it to a server row number by subscripting the table field
(Invoice.Positions[3].Quantity == 5; explicit alias .row(3)). All comparison and collection
operators apply. See select() for full examples and the emitted XML.
4. Field access by subscript
At instance level obj["FieldName"] is equivalent to attribute access, useful for dynamic
models, generic tooling and templates where the field name is only known at runtime. It reads
whatever the attribute reads: an ECMTableList of rows for a table field, the scalar value for
everything else.
document = ecm.dms.get(Incoming, 12345)
document["Subject"] # same as document.Subject
document["History"] # ECMTableList, same as document.History
document["History"][0]["Date"] # column value of one row
Both the attribute name and the ECM internal field name resolve, for table rows as well.
A name that is neither declared nor loaded raises a KeyError naming it. A declared field the
query did not return (e.g. after fields()) still reads as None, exactly as attribute access
does. In Jinja the KeyError becomes a named Undefined instead of a loop over a silent None:
document["Subjekt"] # KeyError: 'Subjekt'
"Subject" in document # True, check without an exception
Dynamic models declare nothing, so there a field the server did not return is indistinguishable
from a typo and raises KeyError as well. The in test is the check without an exception.
Writing is symmetric: a table field name takes a list of dicts or row instances and goes through
the same conversion as the attribute assignment, touched mark included. Undeclared names are
still accepted as scalars, which is how dynamic models set their fields.
document["Subject"] = "Objection"
document["History"] = [{"Date": "2026-01-31", "Text": "Received"}] # typed rows
5. system properties
Every model instance exposes a system attribute that holds all server-populated metadata. Index fields (your ECMField declarations) and system properties are kept strictly separate.
5.1. system fields in queries (class access)
The system attribute behaves differently depending on the access level:
-
Instance access (
obj.system.id) returns the loaded value (e.g.int). -
Class access (
Model.system.id) returns anECMFieldusable inwhere(),order_by()andfields(), exactly like an index field. Sort and result field entries automatically receive thesystem="1"attribute.
This lets you use the same dotted path inside a query. Typical example — all documents inside a known folder via a cross-type condition on the folder ID:
docs = (
ecm.dms.select(PostDoc)
.where(Post.system.id == folder.system.id) # left: class (ECMField), right: instance value (int)
.limit(1)
.execute()
)
The produced condition is bound to the object type of the model it is called on (Post) and is placed into its own ConditionObject by the HOL builder — identical to Post["OBJECT_ID"], including the automatic system="1" attribute. Only system fields backed by a single queryable column are offered as an ECMField; aggregate values without a backing field (system.rights, system.name, system.is_modified, …) remain instance-only.
Queryable fields are: the Always available fields with single-column backing (id, owner_guid, creator, creation_date, last_modifier, last_modified, creation_time, deleted_at), the type-dependent fields (folder_id, parent_register_id, register_id, register_type_id, system_id, foreign_id, lock_user_id, archivist, archiving_date), and the queryable subset of system.base_params (creator, creation_date, modifier, modified_date, links_count, locked_user_id) and system.file_properties (count, size). Fields without a queryable server column (e.g. system.base_params.version, system.file_properties.extension) stay instance-only.
5.1.1. Generic item access for arbitrary system fields
The .system namespace only offers the curated, typed subset. For any server system field — including ones without a .system property (OBJECT_FLAGS, OBJECT_RETENTION, OBJECT_MAIN, …) — and for dynamic models without declared fields, generic item access on the model class is available:
ecm.dms.select(PostDoc).where(Post["OBJECT_ID"] == folder.system.id) # same as Post.system.id
ecm.dms.select(PostDoc).where(Post["OBJECT_FLAGS"] == 0) # system field without a .system property
Model["NAME"] returns an ECMField bound to the object type; if NAME is a member of the SystemFields enum (e.g. OBJECT_ID), system="1" is set automatically at build time. Item access is untyped (ECMField[str], no IDE completion); Model.system.<field> is typed and discoverable but limited to the curated subset. Both produce the same condition. The same item access also works for undeclared index fields (Model["MyField"]).
5.2. Always available
The following properties are always populated, regardless of which flags were passed to the query or get call:
| Property | Type | Description |
|---|---|---|
|
|
Numeric ID of the object on the server. |
|
|
Display name of the object as stored on the server. Usually the value of the key field. |
|
|
|
|
|
|
|
|
Names of the table fields the caller emptied on purpose — assigned, passed, or mutated ( |
|
|
Internal field names that have been modified since loading, mapped to their current value. |
|
|
Table field names whose rows have been added, removed, or changed since loading, mapped to their current row list. |
|
|
Returns |
5.3. Available per object type
Some system properties are only present on specific model types:
| Property | Type | Available on | Description |
|---|---|---|---|
|
|
|
ID of the parent folder ( |
|
|
|
ID of the directly enclosing register ( |
|
|
|
ID of the parent register ( |
|
|
|
Type ID of the parent register ( |
|
|
|
ID of the external archive system ( |
|
|
|
Reference to the document in the external archive system ( |
|
|
|
Numeric ID of the user who currently holds a lock on this document ( |
|
|
|
|
|
|
|
Raw archive-status code ( |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The archive and lock properties (
The search-flag properties |
5.4. Optional — loaded on request
The following properties are None by default and must be explicitly requested via flags on select(), get(), insert_and_get(), or update_and_get().
5.4.1. system.rights
| Attribute | Type | Description |
|---|---|---|
|
|
May create child objects (registers or documents in a folder; documents in a register). |
|
|
May modify the index fields of the object. |
|
|
May read the file of the document. |
|
|
May modify the file of the document. |
|
|
May delete the object. |
Each attribute is True exactly when the server grants the right; a right that is not granted is False. The insert quotas are requested along with the rights so that insert carries a real value.
When the server returns a <Rights> element it never evaluated (all attributes -1, which happens when the query did not request rights), system.rights stays unpopulated and accessing it raises a ValueError as usual.
folder = ecm.dms.get(InvoiceFolder, 12345, rights=True)
if folder.system.rights.edit_metadata:
print("editing allowed")
5.4.2. system.base_params
Populated when base_params=True (get()) or .base_params() (select()) is passed. Type: ECMModelBaseParams.
| Attribute | Type | Description |
|---|---|---|
|
|
Username of the user who created the object. |
|
|
Timestamp of object creation. |
|
|
Current owner of the object. |
|
|
Username of the user who last modified the object. |
|
|
Timestamp of the last modification. |
|
|
Number of links to this object. |
|
|
Number of text notices (remarks) attached to the object. |
|
|
Lock state of the object: |
|
|
Numeric ID of the user holding the lock, or |
|
|
Number of PDF annotations attached to the object. |
|
|
Version number of the object. |
|
|
Display text of the archive state, or |
|
|
Numeric value of the archive state (from the |
folder = ecm.dms.get(InvoiceFolder, 12345, base_params=True)
print(f"Created by {folder.system.base_params.creator} on {folder.system.base_params.creation_date}")
5.4.3. system.file_properties
Populated when file_properties=True (get()) or .file_properties() (select()) is passed. Only meaningful for ECMDocumentModel. Type: ECMModelFileProperties.
| Attribute | Type | Description |
|---|---|---|
|
|
Number of files (primary and secondary files). |
|
|
Total file size in bytes. |
|
|
File extension (e.g. |
|
|
MIME type (e.g. |
|
|
MIME group (e.g. |
|
|
ID of the file type icon. |
|
|
Number of pages, if known. |
doc = ecm.dms.get(InvoiceDocument, 42, file_properties=True)
fp = doc.system.file_properties
print(f"{fp.extension}, {fp.size} bytes, {fp.documentpagecount} pages")
5.4.4. system.variants
Populated when variants=True (get()) or .variants() (select()) is passed. Only meaningful for ECMDocumentModel with the W-module enabled. Type: list[ECMModelDocumentVariant].
Each entry in the list has:
| Attribute | Type | Description |
|---|---|---|
|
|
Document ID of this variant. |
|
|
Version label (e.g. |
|
|
|
|
|
ID of the parent variant, or |
|
|
The child variants branched from this one. |
|
|
Nesting depth in the variant tree: |
walk() returns a node and, depth first, every node below it — handy for searching the tree without recursing manually:
for root in doc.system.variants:
for variant in root.walk():
print(" " * variant.level, variant.doc_ver)
Without a typed load, variants() returns the same nodes from an object ID alone.
5.4.5. system.active_variant
The active variant from the loaded variant tree, searched at any level. Derived from system.variants, so no further server call is involved — in a query with .variants() the access costs nothing per hit. Type: ECMModelDocumentVariant | None; None when the document has no variants.
for doc in ecm.dms.select(InvoiceDocument).variants().limit(100).stream():
active = doc.system.active_variant
print(doc.system.id, active.doc_ver if active else "no variants")
When variants was not requested, the property raises ValueError just like system.variants — the model cannot reload it, as it holds no connection. For a document that is not loaded, use active_variant() on the ecm.dms namespace.
doc = ecm.dms.get(InvoiceDocument, 42, variants=True)
for v in doc.system.variants:
print(v.doc_ver, "✓" if v.is_active else "")
6. Change tracking
Model instances track field changes automatically. Assigning a new value to an ECMField marks that field as modified:
folder = ecm.dms.get(InvoiceFolder, 12345)
folder.Title = "Updated Title"
print(folder.system.is_modified) # True
print(folder.system.modified_fields) # {"Title": "Updated Title"}
print(folder.system.is_field_modified("Year")) # False
update() reads system.is_modified to decide whether to skip the server call. When no fields have changed and no files are provided, the call is omitted silently (unless force=True).
Removing a row from a ECMTableField sets system.has_removed_table_rows = True, which causes update() to add REPLACETABLEFIELDS=1 to the request.
A table field’s row list is an ECMTableList that also records that the caller touched it. Assigning a list, passing one to the constructor, and every mutating call (append, clear, del, …) mark it; lists built from a server response or from the class defaults stay unmarked. A marked list that ends up empty is a deliberate clear, which is how upsert() tells Positions=[] from a table field nobody named:
folder = InvoiceFolder(Title="Invoice 2024")
print(folder.Positions.touched) # False — the implicit default
print(folder.system.explicitly_emptied_table_fields) # []
folder.Positions = [] # naming it is intent
print(folder.system.explicitly_emptied_table_fields) # ["Positions"]
7. See also
-
Workflow Model — the typed model for workflow input variables
-
select() — query builder with
.rights(),.base_params(),.file_properties(),.variants(),.remarks() -
get() — load a single object by ID with optional flag parameters
-
insert() / insert_and_get() — create new objects
-
update() / update_and_get() — write changes back to the server