Module Editor

Where ModulesAPI (client.modules) is read-only discovery, client.modules_admin (ModulesAdminAPI) is the write surface for the Application/Module Editor — create modules, add and alter fields, track pending changes, and publish.

All examples below were run against a live FortiSOAR appliance; the outputs shown are real (trimmed for length).

How the editor really works

FortiSOAR keeps schema in two parallel stores, and a separate physical layer:

Store / layer

Endpoint

Holds

Staging

/api/3/staging_model_metadatas

the editable draft of every module

Published

/api/3/model_metadatas

the committed schema records reads use

Physical table

/api/3/<module>

only created when a global publish runs its migration

Both stores mirror all modules. A module has an uncommitted change when its staging record differs from its published one. Creating a module or editing a field touches staging only — nothing is live until you publish(), which runs an appliance-wide backup + DB migrate cycle and creates the table.

Warning

Publish is appliance-wide. PUT /api/publish promotes every pending staged change across the whole instance, not just modules you touched. On a shared box, check pending_changes() first.

Walkthrough: two linked modules (a heist tracker)

Before the reference sections, here’s the whole arc end to end. We’ll build a tiny heist tracker: a crew module (the people pulling the job) and a heists module (the jobs), linked so a heist has a whole crew and a crew member has a rap sheet of heists. The fun part — you only declare the link once; the SDK stages the reverse side for you.

admin = client.modules_admin

# 1. The crew. Each member has a name and a specialty.
admin.create_module(
    "crew",
    label="Crew Member",
    plural="Crew",
    fields=[
        admin.text_field("alias", required=True),  # "The Brains", "Wheels"  (grid column by default)
        admin.picklist_field("specialty", "AlertType"),              # reuse any existing picklist
        admin.checkbox_field("trustworthy"),
    ],
    record_uniqueness=["alias"],
)

# 2. The heists. The `crew` field is the link — a many-to-many relationship to
#    the module we just made. We declare it ONLY here.
admin.create_module(
    "heists",
    label="Heist",
    plural="Heists",
    fields=[
        admin.text_field("codename", required=True),  # "Operation Cannoli"
        admin.text_field("target"),
        admin.integer_field("takeUsd"),
        admin.datetime_field("goTime"),
        admin.relationship_field("crew", "crew", label="Crew"),         # <-- the linkage
    ],
)

create_module returns the created staging record — the draft, not yet live (@type is StagingModelMetadata). The call below is doctested against a real capture of a throwaway module, so the return shape is exactly what the appliance sends: the module identity plus its seeded name field. create_view_templates and add_to_nav are turned off here to keep the example to the one write:

>>> admin = demo_client().modules_admin
>>> mod = admin.create_module(
...     "doctestmod", label="Doctest Module", plural="Doctest Modules",
...     create_view_templates=False, add_to_nav=False,
... )
>>> mod["@type"]                       # the staging (draft) store, not model_metadatas
'StagingModelMetadata'
>>> mod["type"], mod["displayName"]
('doctestmod', '{{ name }}')
>>> mod["descriptions"]
{'singular': 'Doctest Module', 'plural': 'Doctest Modules'}
>>> [a["name"] for a in mod["attributes"]]   # the auto-seeded required field
['name']

That single relationship_field is the whole trick. Because the SDK keeps both sides of a relationship valid, it auto-stages the reverse field on crew — so each crew member gets a heists field listing every job they’re on, without you touching the crew module again:

[a["name"] for a in admin.get_staging("crew")["attributes"]]
# ['alias', 'specialty', 'trustworthy', 'heists']   <-- 'heists' appeared on its own

Nothing is live yet — both modules are staging-only drafts. Check what a publish would commit, then commit it (remember: publish is appliance-wide). The pending_changes() call below is doctested against a scoped overlay (pending_create_overlay) that stages crew + heists without publishing — the exact post-create_module, pre-publish state:

>>> from pyfsr._testing.client_captures import pending_create_overlay
>>> admin = demo_client(overrides=pending_create_overlay(["crew", "heists"])).modules_admin
>>> admin.pending_changes()
[PendingChange(module='crew', change='created'), PendingChange(module='heists', change='created')]
admin.publish()   # backup + migrate; blocks ~30–60s while /api/3 is down

Now the tables exist and you can populate the caper. Create the crew, then a heist that references them — the link is just a list of record IRIs:

danny  = client.records("crew").create({"alias": "The Brains",  "trustworthy": True})
linus  = client.records("crew").create({"alias": "Light Fingers", "trustworthy": True})

job = client.records("heists").create({
    "codename": "Operation Cannoli",
    "target": "Bellagio Vault",
    "takeUsd": 150_000_000,
    "crew": [danny["@id"], linus["@id"]],   # link by IRI
})

Because the reverse field exists, the relationship reads both ways for free — ask a heist for its crew, or a crew member for their heists:

client.records("heists").get(job["uuid"], relationships=True)["crew"]
# -> [{'alias': 'The Brains', ...}, {'alias': 'Light Fingers', ...}]

client.records("crew").get(danny["uuid"], relationships=True)["heists"]
# -> [{'codename': 'Operation Cannoli', ...}]

That’s the full loop: two create_module calls, one relationship, one publish — and a bidirectional link you only had to describe once. The rest of this guide is the reference behind each step.

Inspecting existing schema (read-only)

These read-only calls are doctested against captured appliance responses (demo_client()), so the outputs below are real:

>>> client = demo_client()
>>> admin = client.modules_admin
>>> admin.is_published("alerts")
True
>>> admin.is_published("nonexistentmod")
False
>>> pub = admin.get_published("alerts", typed=True)
>>> (pub.type, pub.module)            # PublishedModelMetadata
('alerts', 'alerts')
>>> sev = admin.get_field("alerts", "severity", typed=True)
>>> (sev.name, sev.type)             # AttributeMetadata
('severity', 'picklists')
>>> admin.pending_changes()          # fully-published box: nothing staged
[]

get_published / get_staging return the raw record dict (with every field under attributes) when called without typed=True; pass typed=True for the matching PublishedModelMetadata / StagingModelMetadata / AttributeMetadata model shown above.

Note

is_published() reports presence in model_metadatas. A freshly created module is staging-only until you publish, so it reads False until then.

Building fields

Tip

For the full field-type catalogue — every display type, its storage type, properties, and relationship/reverse-field semantics — see Module & Field Schema Reference. This section is a quick start; that page is the authoring reference.

Prefer the typed builders, which set the storage type and formType (display type) to a matching pair for you (e.g. a datetime field must store integer; a text field must store string):

admin.text_field("summary", area=True)     # string / textarea
admin.integer_field("score")                # integer / integer
admin.datetime_field("detectedOn")          # integer / datetime
admin.checkbox_field("isExternal")          # boolean / checkbox
admin.object_field("payload", label="Payload")   # object / object

Warning

There is no text storage type (and no json type). Text fields store string; JSON stores object. Hand-setting db_type="text" stages fine but fails at publish (“Attribute type ‘text’ does not exist”). The typed builders avoid this entirely.

field() is the low-level escape hatch where you set both axes yourself; admin.typed_field(name, display_type) derives the storage type for any scalar display type. The object field above produces:

{
  "name": "payload",
  "type": "object",
  "formType": "object",
  "descriptions": {"singular": "Payload"},
  "displayName": "{{ payload }}",
  "searchable": false,
  "collection": false,
  "visibility": true,
  "readable": true,
  "writeable": true,
  "validation": {"required": false, "minlength": 0, "maxlength": 10485760}
}

Field options

field() mirrors the editor’s Properties panel. Beyond db_type/form_type, it exposes the full options surface. grid_column (Default Grid Column) is on by default for scalar, lookup and picklist fields — they show in the module’s list/grid view without opting each one in — and off for password, object/json/array and collection relationships (manyToMany/oneToMany), the types that are never grid columns in practice. Override either way with grid_column=True/False:

admin.field(
    "secret",
    label="API Secret",          # Field Title (name is the immutable API Key)
    editable=True,               # UI "Editable"  -> writeable
    searchable=False,            # Field Options row...
    grid_column=False,           # "Default Grid Column" — text defaults visible; hide this one
    encrypted=True,              # "Encrypted" (mutually exclusive with searchable)
    required=True,               # or a condition dict for "Required by condition"
    visibility=True,             # or a condition dict for "Visible by Condition"
    default_value="",
    tooltip="Stored encrypted",
    minlength=0, maxlength=1024, enable_range=True,   # Length Constraints
    bulk_edit=True,              # "Allow Bulk Edit" -> bulkAction.allow
)

# the default picks a sensible value per type, so most fields need no grid_column at all:
admin.password_field("apiKey")               # -> gridColumn: false  (default for password)
admin.text_field("notes", grid_column=False) # scalar, but kept out of the list view

Picklist and relationship fields

# single- or multi-select picklist, bound to a picklist list name
admin.picklist_field("severity", "AlertSeverity")
admin.picklist_field("tags", "AlertType", multi=True)   # -> multiselectpicklist

# a single reference to one record of another module (many-to-one, no reverse field)
admin.lookup_field("owner", "people", label="Owner")

# a many-to-many relationship to another module (reverse field auto-created on target)
admin.relationship_field("relatedalerts", "alerts", label="Related Alerts")

Note

add_field keeps both sides of a relationship valid: it creates the reverse field on the target when the platform won’t (the oneToMany target lookup, the custom-inverse manyToMany mirror). Pass create_reverse=False to manage the target side yourself. See Module & Field Schema Reference for the per-relationship rules and reverse_field() verification.

Creating a module

create_module posts to staging and — matching the in-product editor — also creates the default list/detail/form layouts so the module renders in the UI. Pass create_view_templates=False for an API-only module. The keyword flags map directly to the editor’s Additional Settings.

admin.create_module(
    "widgets",
    label="Widget",
    plural="Widgets",
    fields=[
        admin.text_field("name", required=True),
        admin.text_field("payload", area=True),
        admin.picklist_field("severity", "AlertSeverity"),
        admin.relationship_field("relatedalerts", "alerts"),
    ],
    # Additional Settings:
    ownable=True,                # Team Ownable (also sets userOwnable)
    trackable=True,
    indexable=True,
    taggable=True,
    queueable=False,
    recycle_bin=True,            # Enable Recycle Bin -> softDeleteable
    multi_tenancy=False,         # Enable Multi-Tenancy -> peerReplicable
    record_uniqueness=["name"],  # uniqueConstraint
    default_sort=[{"field": "createDate", "direction": "DESC"}],
)
# staging record -> {'uuid': '868221dc-...', 'type': 'widgets',
#                    'module': 'widgets', 'displayName': '{{ name }}'}

admin.get_view_templates("widgets")
# layouts created -> ['detail', 'form', 'list']

Edit staged fields before publishing:

admin.add_field("widgets", admin.email_field("reporter"))
admin.set_field_type("widgets", "payload", db_type="object", form_type="object")

[(a["name"], a["type"], a["formType"]) for a in admin.get_staging("widgets")["attributes"]]
# [('name', 'string', 'text'), ('payload', 'object', 'object'), ('reporter', 'string', 'email')]

Editing settings on an existing module

set_module_settings updates the Additional Settings (and display template / sort) of a staged module, using the same friendly names as create_module:

admin.set_module_settings(
    "widgets",
    taggable=False,
    ownable=True,                       # also syncs userOwnable
    recycle_bin=True,                   # -> softDeleteable
    display_template="{{ name }}",
    default_sort=[{"field": "createDate", "direction": "DESC"}],
)

Note

Auto-mirror appliances. Some builds (e.g. with the dev-mode schema toggle on) re-sync staging_model_metadatas into model_metadatas on every write — so a staged create or edit shows up in the “published” store immediately, and a settings PUT can surface a sync error in its response even though the staging row updated. Because of this, set_module_settings confirms the change by re-reading staging and only raises if a value did not actually take. It’s also why is_published() may read True for a module you have not explicitly published on such a box.

Tracking pending changes

Before an appliance-wide publish, see exactly what would be committed. pending_changes() diffs staging against published:

admin.pending_changes()
# [{'module': 'widgets', 'change': 'created'}]
#   change is one of: 'created' | 'modified' | 'deleted'

An empty list means the appliance is fully published — nothing for publish() to do.

Publishing

admin.publish()   # appliance-wide commit; blocks until the migrate cycle finishes

PUT /api/publish only starts the publish — its response is {"status": "started"} — and the backup + DB migrate then runs asynchronously, during which the whole API (/api/3) returns 503 for ~30–60s. By default publish() is synchronous: it waits out that outage and confirms the result via /api/publish/error (a fresh last_publish_time with status: "Success"), returning that body so you can read the published schema immediately. It is always synchronous — during the migrate the whole appliance is down, so there is nothing else to do but wait.

Note

Validation errors are raised synchronously, before any migrate. A field whose type does not exist, or a oneToMany with no matching lookup on its target, comes back as an APIError (HTTP 400) on the PUT itself — its message is the appliance’s own (e.g. “there is no lookup field present in ‘alerts’ module”), so surface it to the user. If the async publish fails instead, publish() raises FortiSOARException with the status from /api/publish/error; a publish that never reports back raises TimeoutError.

Discarding an unpublished draft

discard_staging_draft fires the same DELETE the editor’s Revert button uses, and additionally cleans up the module’s view templates (which the UI’s own revert leaves orphaned):

admin.discard_staging_draft("widgets")   # -> True
admin.get_view_templates("widgets")      # -> []   (cleaned up)

Danger

There is no API path to delete a published module. discard_staging_draft only undoes an unpublished draft. If a module was ever published (its draft committed by any publish on the appliance), the live module and its Postgres table remain, with no API to remove them — that needs backend CLI/SQL. For a clean throwaway, never publish it; then discarding the draft removes it entirely.

Note

Auto-mirror appliances are the exception to “discarding removes it entirely.” On a box that re-syncs staging into model_metadatas on every write (see the settings note above), even a never-explicitly-published module has already been mirrored into model_metadatas by its create/edit writes. There, discard_staging_draft removes the staging draft but leaves a stale published stub (a model_metadatas row with no physical table). Clearing that stub needs one publish() — which reconciles the mirror and drops the orphaned row. So on auto-mirror boxes the throwaway recipe is create → discard_staging_draft → one publish(), after which is_published() reads False and pending_changes() is empty.