Import Scripts

An Import Script reads a file you upload and creates objects in the TrialGrid system by reading the contents of the file and executing commands to modify data in the system.

You write an Import Script once for your Organization. Other people in your Organization then run it whenever they have a file to load.

Note

Import Scripts are being built in stages. At present you can register a script, write its code, test it against a file to see what it would do, and then apply what it asked for. Commands that act on your Organization or on a URL can be applied, and a command can say which URL it means. See Applying a run and Saying where a command acts below.

Registering a script

To see the Import Scripts for your Organization:

  1. Go to your Organization home page.

  2. Click the "Import Scripts" card.

You will see the scripts already registered, with the Scope of each and whether each one is Active.

Click "Add Import Script" to register a new one. You will be asked for:

Name

What the script is called. Names must be different from each other within your Organization.

Description

What the script does, so that someone choosing a script knows which one they want.

Scope

The level the script runs at. See Choosing a Scope below.

Active

An Inactive script is kept but is not offered to be run. New scripts start as Inactive so you can finish writing and testing one before anyone can use it.

Choosing a Scope

The Scope sets the level the script runs at. It decides what the script is told about its environment when it runs.

Scope

The script is told about

Organization

The Organization

URL

The Organization and the URL

Project

The Organization, the URL and the Project

Draft

The Organization, the URL, the Project and the Draft

Pick the level the file belongs to. A file listing forms to copy into a new Draft belongs to a Project, so that script has Project scope. A file that creates a set of Projects belongs to a URL, so that script has URL scope.

The Scope also sets where a command acts by default. A command can say otherwise, which is how one script can build the same things in several URLs. See Saying where a command acts below.

Writing the code

The Code tab holds the body of the script. The editor shows line numbers and colors the code as you type.

When you save, TrialGrid checks that the code is valid. If it is not, the page tells you what is wrong and which line it is on, and your work is kept on screen so you can correct it. Nothing is run at this point. The check only confirms that the code can be read.

You can save a script with no code in it. This lets you register a script and come back to write it.

What your script is given

Your code is handed one name, importer, and everything it can reach hangs off that.

What

For

importer.settings

The values the person running the script filled in, and the files they chose

importer.context

The object the run is pointed at, and the levels above it

importer.context.location

Where the run is pointed, as a location a command can be given

importer.log, importer.warn, importer.error

Writing to the run's log

importer.execute

Asking for a command to be executed

Location

Building a location from an object the script has access to, with Location.from_object

Your script is ordinary Python, with some things left out for safety. You cannot import modules, open files, or reach the network. If you use a name that is not available the system will show an error and which line the error is on.

You can write your own functions with def, and use lambda, list comprehensions, +=, and the methods of text, lists and dictionaries such as startswith, split and append. A list can be passed as a call's arguments with *, and a dictionary as its named arguments with **, as in importer.execute("CreatePropertyDefinition", applies_to="Form", **prop). Names starting with an underscore are not allowed.

These built-in functions are available, along with True, False, None and the usual exceptions such as ValueError:

abs, all, any, bool, bytes, callable, chr, compare, compare_to_standard, complex, delattr, dict, divmod, enumerate, float, frozenset, hash, hex, id, int, isinstance, issubclass, len, list, max, min, oct, ord, parse_json, pow, range, repr, reversed, round, set, setattr, slice, sorted, str, sum, tuple, zip

parse_json reads JSON text into dictionaries, lists, text, numbers and None. Use it where text you are reading holds JSON, such as a Custom Property or a spreadsheet cell. A JSON File setting needs none of this: it arrives already read, through .root. parse_json raises ValueError when the text is not JSON, so a script can name what was wrong and carry on:

for form in importer.context.draft.als_forms_set.all():
    mapping_property = form.properties["field mapping"]
    if not mapping_property.is_set:
        continue
    try:
        mapping = parse_json(mapping_property.value)
    except ValueError as error:
        importer.log(f"{form.OID}: the field mapping is not valid JSON. {error}")
        continue
    importer.log(f"{form.OID} maps {len(mapping)} fields")

NaN, Infinity and -Infinity are refused the same way. Some programs write them into JSON, but JSON itself does not define them, and NaN is not equal to anything, not even to itself. A row your script reports now is easier to deal with than one that silently matches nothing later. A JSON File setting holding one of them, or a YAML File setting holding .nan or .inf, is refused for the same reason, when the file is read.

compare_to_standard compares an object with its Library Standard, and compare compares two objects of the same kind directly, as the Compare dialog does. Each returns the differences, Standard Rules and explanations as dictionaries and lists, which Comparing Objects describes. compare_to_standard returns None when the object has no Library Standard you can view. Both raise ValueError when the objects cannot be compared, such as a Form and a Folder:

forms = importer.context.draft.als_forms_set.order_by("Ordinal")
for form in forms:
    result = compare_to_standard(form)
    if result is None:
        importer.log(f"No Library Standard for {form.OID}")
        continue
    for row in result["attributes"]:
        if row["state"] not in ("same", "not_compared"):
            values = f"{row['source_value']} / {row['target_value']}"
            importer.log(f"Against its Standard, {form.OID} {row['name']}: {values} ({row['state']})")
    for collection in result["children"]:
        for child in collection["objects"]:
            if child["state"] not in ("same", "not_compared"):
                name = f"{collection['name']} {child['name']}"
                importer.log(f"Against its Standard, {form.OID} {name}: {child['change']} ({child['state']})")
try:
    compare(forms.first(), importer.context.draft)
except ValueError as error:
    importer.log(f"Not compared: {error}")

Others you might expect, such as map, filter, getattr and type, are not. Use a list comprehension instead of map or filter. print writes nothing: use importer.log, which takes text, so write importer.log(f"{count} fields") rather than importer.log(count).

A script changes nothing by itself. It asks for changes with importer.execute, and the Command Reference lists every command it can ask for. Commands create, copy and link things, and write a Project's wiki pages, which keep their earlier versions. None of them deletes anything, and none changes a Form, Field or other object a Draft already holds, so a script cannot remove or edit those.

A file is one of the settings a script declares, so it is read as importer.settings["name"] like any other value. What it offers depends on the kind of file the setting is for. An Excel file has sheets and a JSON or YAML file has a document, and both are covered in Reading a file.

Writing to the log

The log is how a script explains itself to whoever runs it. It is useful to add logging statement to show what decisions or actions your script is taking.

importer.log("Reading the form list")
importer.warn("No Notes column, so notes will be left empty")
importer.error("This file has no Fields sheet")

importer.error records a problem but does not stop the run. Use it to report every bad row rather than only the first. To stop, raise instead:

raise ValueError("This file is not one this script can read")

The run then ends, and the log says where:

Exception on line 2: This file is not one this script can read

Settings

A script is given its inputs as settings. A file is one of them, and so is anything a file could not tell the script: a script that copies forms from a library has to be told which library, and no spreadsheet column can answer that.

You declare the settings a script needs on the Settings tab. The person running the script is then asked for those values each time. You are asked for them when you test a script, so you can try a script with different values.

Each setting has:

Name

The name the script uses to read the setting. Keep it short and do not change it once the code uses it.

Display Name

The label shown to the person running the script.

Description

Help text shown under the field.

Type

What kind of value it is. See the table below.

Options

For a Choice setting, the values to choose from, separated by commas.

An option cannot contain a comma. If you type Yes, please,No you get three options, Yes, please and No, not two. Space around a comma is removed, so Yes, No and Yes,No give the same two options.

Default Value

The value the run form starts with. The person running can change it or clear it. Clearing it means the script gets no value for that setting, not the default.

Required

Whether the script can be run without a value.

Setting types

Type

What the person running the script gives

Text

Any text

Boolean

True or False

Choice

One of the options you list

Integer

A whole number

Draft

A Draft to copy from

Project

A Project

URL

A URL

Excel File

An .xlsx file to upload

JSON File

A .json file to upload

YAML File

A .yaml or .yml file to upload

Draft, Project and URL settings hold an object rather than a typed value. These cannot have a default value, because an object belongs to one Organization and not everyone may use it. Leave the Default Value empty for them, and the person running the script chooses the object each time.

The three file settings hold an uploaded file, and they cannot have a default value either: the file is chosen when the script is run. Declare one for each file your script reads. A script may declare none, in which case it is simply run and reads nothing, or several, in which case the person running it is asked for each one.

The order of the rows is the order the settings are shown in. Drag a row to move it.

Checks on the settings you enter

Two settings on the same script cannot share a Name or a Display Name. A Choice setting needs at least one option, and its default has to be one of them. A Boolean default has to be True or False, and an Integer default has to be a whole number. An object or file setting cannot have a default at all. If any row has a problem the page tells you which row and does not save, and your rows are kept on screen.

TrialGrid also compares the settings you declared against the settings your code reads. If your code reads a setting you have not declared, or you declare one the code does not appear to read, you are told after the save. These are notes, not errors. The script is saved either way, because there are good reasons for the two to differ: your code might read a setting only in certain cases, or work out the name while it runs.

Testing a script

The Code tab has a Test panel beside the editor. Use it to run your script against a real file and see what it would do, without anything being created or changed.

Choose the target, choose a file, fill in any settings your script declares, and select Run Test. TrialGrid runs the saved script, so save your changes first. The panel tells you if you have unsaved work.

Choosing the target

The target is the object your script works in. Which kind it is comes from the script's scope: a Draft scope script is pointed at a Draft, a Project scope script at a Project, and a URL scope script at a URL. An Organization scope script has no target to choose. It runs against your Organization.

Start typing in the target box to search. The list holds only the objects you are allowed to see, inside the Organization that owns the script. It is searched as you type because an Organization can hold many thousands of Drafts.

Drafts and Projects are listed under the URL and Project they belong to. A Project that holds Standard libraries is marked with a library icon, so you can tell a library Draft from a study Draft before you choose one.

Your choice is checked again when the run starts. If your permission has changed since you chose it, the run stops and tells you to choose another.

Reading the target

Your script reads the target as importer.context:

draft = importer.context.draft
importer.log(draft.DraftName)

for form in draft.als_forms_set.all():
    importer.log(form.OID)

The target also tells your script about the levels above it. A Draft knows its Project, and that Project knows its URL, so importer.context.project and importer.context.url work without you choosing them.

You do not have to read every object in a set to find the ones you want. Ask for them instead, and the Draft is searched for you, which matters in a Draft holding thousands of Fields:

draft = importer.context.draft
checkboxes = (
    draft.als_fields_set.filter(ControlType="CheckBox").select_related("Form").order_by("Form__Ordinal", "Ordinal")
)

current_form = None
for field in checkboxes:
    if field.Form.OID != current_form:
        current_form = field.Form.OID
        importer.log(f"Form {current_form}")
    importer.log(f"  {field.FieldOID}")

Choosing and Counting Objects lists every method a set offers, such as filter, exclude, order_by, count and first, with examples. They are the same in a script and in a Document Template.

The names you use are the same ones a Document Template uses. See the Object Model Reference for what each kind of object gives you. Reading something the reference does not list stops the run and names the line, so a mistyped attribute is reported rather than silently empty.

Custom properties work the same way as in a Document Template. Every property defined for the URL is there, including ones nobody has filled in:

for name, prop in importer.context.draft.properties.items():
    importer.log(f"{name} = {prop.value}")

The properties you get are the ones defined for the URL the object is in. If your script follows a link to an object in a different URL, such as a Standards Draft reached through get_compliance_chain(), you are shown the properties that URL defines, with the values set there.

You can also ask an object type what properties it has, without needing an object of that type. This is useful when a Draft does not hold one yet, for example to check the column headings of a workbook before loading it. A type lists every property defined for it, none of them set:

spec = importer.context.url.customobjectdefinition_set.get(name="Report Specification")
for name, prop in spec.properties.items():
    importer.log(f"{name} is a {prop.value_type} property")

A type also tells you what identifies its objects, which is what you need to copy one. A Draft download writes the objects of a type your Organization defined to a sheet named after its external_name, one column per property, and identifier_parts names the columns whose values make up an object's identifier, in the order they are joined:

kind = importer.context.url.customobjectdefinition_set.get(name="Stock")
importer.log(f"A {kind.name} is written to the {kind.external_name} sheet")
importer.log(f"Its identifier is {kind.identifier_parts} joined with {kind.identifier_separator}")

So a script can read a sheet of a download and build the identifier of each object on it, without being told which of the columns is the identifier:

workbook = importer.settings["workbook"]
kind = importer.context.url.customobjectdefinition_set.get(name="Stock")

for row in workbook.sheet(kind.external_name).rows():
    parts = []
    for heading in kind.identifier_parts:
        parts.append(row.get(heading))
    importer.log(kind.identifier_separator.join(parts))

An external_name is at most 31 characters and never uses a character Excel refuses in a sheet name or the name History, which Excel reserves, so the sheet a download writes has exactly that name.

A built-in type such as Forms or Edit Checks answers identifier_parts too, with the one column of the object that holds its identifier. Take care with a download there: the built-in sheets are the fixed sheets of the Rave ALS format, so neither the sheet name nor the column heading always matches. A Standard Rule is written to the TG_Rules sheet, and its rule_id is written under the heading Identifier.

An Organization script is not pointed at a URL. It reads the URLs of its Organization instead. The list holds only the URLs that the person running the script has permission to view, of the types your Organization is licensed for:

for url in importer.context.organization.raveurl_set.all():
    for kind in url.customobjectdefinition_set.all():
        importer.log(f"{url.name}: {kind.name} has {len(kind.properties)} properties")

Settings that name an object

A Draft, Project or URL setting is chosen the same way as the target, from its own searchable box in the Test panel, with the same library icon on Projects that hold Standard libraries. A Draft box offers only Drafts that allow importing.

What you choose stays chosen when you save the script, so you can edit and run without picking your test values again each time.

Your script reads the choice as it reads anything else:

library = importer.settings["source_library"]

for row in importer.settings["form_list"].sheet("Forms").rows():
    form_oid = row["Form OID"]
    importer.log(f"looking for {form_oid} in {library.DraftName}")

If you leave one empty, reading it stops the run and says nothing was chosen. If the object you chose is one you can no longer use, the run stops before it starts and asks you to choose again.

What you get back

The results have up to three tabs: What it does and Values list the commands your script asked for, and Log between them shows every line your script wrote with importer.log, importer.warn or importer.error, in the order it wrote them. The commands are checked, so a command with values it will not accept is reported here rather than when the script is run for real. The commands are listed only. Nothing is created and nothing is changed. A run that asked for no commands has only the Log tab.

If your script stops with a problem, the results open on Log, which says which line of your code failed and why. Anything the script did before that point is still shown, so a run that logged forty rows and then failed on the forty-first tells you about the forty.

What is kept

A test run is not listed on the Runs tab. Its commands are kept with it, so that it can be applied and its commands downloaded, until one of these happens:

  • You run the test of the same script again. The commands of your earlier runs are then discarded, unless a run has been applied or is being applied.

  • About 30 days pass since the run, or up to a week beyond that. The run and its commands are then removed, whether or not it was applied.

The file you chose is kept so that you can run the test again without choosing it again, and is removed about a day after you chose it, or within a week of that.

What a test run cannot do yet

A test run never carries out the commands your script asks for. They are listed instead, so you can see what would happen without anything being created or changed. Applying the run afterwards is what carries them out.

One name in the Object Model Reference is not available to a script yet, because a Document Template builds it while it renders rather than reading it from the object. It is activities. Reading it stops the run and says so.

Limits

A test run has limits, so one script cannot tie up the system:

  • it can run for up to 3 minutes, and reading a large sheet counts towards that

  • it can write up to 500 log lines, and any beyond that are not kept

  • it can build up to 20,000 commands

  • the file can be up to 20 MB, and a run reads up to a million cells from it. A row counts as at least four cells however narrow it is, because holding a row costs about that much whether it has one value in it or four

Applying a run

A test run changes nothing. It reads your file, works out what it would do, and lists it.

The list is shown two ways. What it does is a sentence for each command, which is what you read to decide whether the run is right. Values shows the same commands with the values each one carries, which is what you read when you are working out why a command is not what you expected.

A run of more than 50 commands is too long to read one by one, so What it does counts them instead: one line for each kind of command and where it acts, such as Create Data Dictionary Entry × 999 in a Draft. After 49 such lines, the rest are counted together as Other commands. Values shows the first 50. Either tab offers the whole list as a download: What it does as text, a sentence for each command, and Values as JSON, every command with the values the script gave it. A command the script sent somewhere other than where the run is pointed also says where.

A long value is shortened so that one command cannot bury the rest, because an icon's drawing runs to thousands of characters. Select Show all beside it to read the whole thing.

If you are happy with the list, select Apply these commands. TrialGrid then carries them out, all of them together: if any one cannot be done, none of them are, and nothing is left half finished.

Your permissions are checked again at this point, for every command. A run that built a command you are not allowed to make stops and tells you which one, and nothing is created.

When the apply finishes it says how many commands it carried out, followed by anything you should know about the result: a value that was changed on its way in, such as a tab replaced by a space, or an object that was already in the Draft and so was not copied. Objects already in a Draft are counted, with one line for each Project, Draft and kind, up to 50 lines. The first 50 of the rest are listed. Anything beyond either limit is counted together at the end.

An apply can run for up to 30 minutes. A very large run, such as many thousands of records added to a Draft that already holds many, can take longer than that. It is then stopped, and nothing is written.

A run can only be applied once. Applying is not undoable, so read the list first.

Only your latest test run of a script can be applied. Running the test again discards the commands of your earlier runs that were not applied, so a run still showing in another browser tab says its commands were not kept.

Saying where a command acts

Every command acts somewhere, and that place is a location: your Organization, a URL, a Project and a Draft. Written down, a location reads as a path - Acme / Study A / Project 1 / Draft 1 - and that is what you see beside each command in a run's list, so two commands doing the same thing in different places can be told apart.

The path shown against a command is the path to where that command acts, which can be shorter than the one the run is pointed at. An icon belongs to your whole Organization, so a command creating one shows only your Organization, even in a run pointed at a Draft.

A command with nothing more said about it acts where the run is pointed. You choose that when you start the run, and a script set to Project or Draft scope reaches the URL that Project or Draft sits in. Most scripts never say where at all:

importer.execute("CreateObjectDefinition", name="Visit", plural_name="Visits",
                 external_name="VIS")

To act somewhere else, give the command a location. The name of the argument says which level the command needs: a command that acts on a URL takes url_location, one that acts on a Draft takes draft_location. Anything ending in _location takes a location; everything else is a value. Start from where the run is pointed and say what is different:

for url_name in ["Study A", "Study B"]:
    importer.execute("CreateObjectDefinition", name="Visit", plural_name="Visits",
                     external_name="VIS",
                     url_location=importer.context.location.with_url(url_name))

importer.context.location is where the run is pointed. with_url, with_project and with_draft each give you a new location with that part changed.

Naming a URL drops the Project and the Draft that were in the old location, because those belonged to the URL you have just moved away from. If you need them, say them as well:

somewhere = importer.context.location.with_url("sandbox").with_project("Test Project 1")

You can also take the location of something the script is already holding, such as a Draft supplied as a setting:

library = importer.settings["source_library"]
somewhere = Location.from_object(library)

A few commands work between two places, and then both are named for what they are. CopyDraftObject copies from one Draft into another, so it takes source_draft_location and target_draft_location rather than one draft_location that would not say which end is which.

Naming the parts yourself

Start from where the run is pointed and name the parts you want:

somewhere = importer.context.location.with_url("Study A").with_project("Project 1").with_draft("Draft 1")

How far down a location names is what decides which commands can use it. That one names a Draft, so it can be given to a command that acts on a Draft, and equally to one that acts on a Project, a URL or your Organization. A command takes the part of the location it needs and ignores what is below it.

A location that stops higher up cannot be used lower down. This one names a Project:

somewhere = importer.context.location.with_url("Study A").with_project("Project 1")

Give that to a command acting on a Draft and the run stops, saying the command acts on a draft and your location does not reach one. Add the Draft to use it there:

somewhere = importer.context.location.with_url("Study A").with_project("Project 1").with_draft("Draft 1")

Starting from importer.context.location matters, because it carries your Organization and a location has to be unbroken from your Organization down. Writing one out with Location(...) and leaving the Organization off gives you a location that reaches nothing, and the run stops with the same message. If you do write one out, name your Organization too:

somewhere = Location(organization_name="Acme", url_name="Study A", project_name="Project 1")

A location written from names is refused in two cases where one from Location.from_object is not. A location built from an object carries that object's id as well as its name: the name is what decides which thing the command acts on, and the id only chooses between things of the same name.

  • Two things share the name. If the Project holds two Drafts called "Draft 1", a location naming "Draft 1" cannot say which, and the run stops and tells you so.

  • Some arguments need the id. CopyDraftObject will not accept a source_draft_location built from names alone, and neither will LinkDraftToLibrary for its library_draft_location. Both want the Draft the id names, because what you are copying from has to be the exact Draft somebody approved copying from, not whatever now carries that name.

So use Location.from_object for anything the script is holding, and name the parts yourself when you are reaching something it is not, such as a URL named in your file.

What you need to be allowed

You need permission on whatever the location names, for each command. Naming a URL does not give you access to it. A location naming a URL you cannot use is refused exactly as one naming a URL that does not exist is, and for the same reason: a script must not be able to work out what is in your Organization that you are not allowed to see.

The permission asked for is the one the same action needs elsewhere in TrialGrid. Creating an object type or a property asks for Manage Metadata, a document template asks for Manage Document Templates, and a custom diagnostic asks for Manage Custom Diagnostics. Your permissions are read when you apply, not when the run was made, so a permission taken away in between stops the command.

The URL's Organization must be licensed for the system that URL belongs to. A run against a URL whose Organization is not licensed for it stops and says so.

When a location is refused

A location has to reach the level the command acts on. A script set to Organization scope is not pointed at a URL, so a command that acts on one has to name a location that reaches a URL. If it does not, the run stops and says which command and how far its path got.

A location also has to name one thing. If two URLs in your Organization share a name, or two Projects in a URL, or two Drafts in a Project, a location cannot tell them apart. Applying the run stops rather than guessing, so you will see this when you apply rather than when you run. Point a setting at the one you mean, or give them different names. A setting holds the one object that was chosen when the run was set up, so a location taken from it can tell two objects of the same name apart. The name still has to match when you apply: if the object is renamed between running the script and applying it, the run stops, because the name you approved is no longer the name it points at.

Note

If the script's scope is changed after a run is made, that run can no longer be applied. The list you approved was built for a different scope, so it is not the list the changed script would produce. Run the script again.

Names that belong to your Organization

Some things are named across the whole system rather than within your Organization. An icon is one. So that two Organizations can each have an icon called circle, TrialGrid puts your Organization in front of the name you give, exactly as it does when you add an icon by hand.

Write the plain name in your script:

importer.execute("CreateIcon", name="circle", svg_content=svg)

and for an Organization called Acme the command will read Create icon acme-circle, which is the icon you will get. The list you approve always shows the name that will exist, not the one you typed.

Do not put your Organization in the name yourself. name="acme-circle" would make acme-acme-circle, so it is refused and the run tells you to write circle instead.

Creating records from a file

A script can create records of the object types defined on your URL. This is the usual reason for writing one: a file holds rows of information, and each row should become a record.

CreateDraftObject creates one record. You give it the object type and the values the record holds:

importer.execute(
    "CreateDraftObject",
    type="Visit",
    attributes={"Name": "Screening", "Day": "1"},
)

The attribute names are the properties of that object type, named as they are named there. Capital letters do not have to match. An attribute the object type does not have stops the apply, so a misspelled column name is reported rather than quietly dropped.

A sheet of rows then reads like this:

for row in importer.settings["visits"].sheet("Visits").rows():
    importer.execute(
        "CreateDraftObject",
        type="Visit",
        attributes={"Name": row["Name"], "Day": row["Day"]},
    )

If the same kind of information is spread over several sheets, walk the sheets as well:

for sheet_name in importer.settings["visits"].sheet_names:
    for row in importer.settings["visits"].sheet(sheet_name).rows():
        importer.execute(
            "CreateDraftObject",
            type="Visit",
            attributes={"Name": row["Name"], "Day": row["Day"]},
        )

The identifier of each new record is worked out from its values, the same way it is when somebody fills the form in. A record the Draft already holds under that identifier is refused, so a file loaded twice does not quietly create everything a second time.

That check needs an identifier with no question mark in it. A row that leaves out one of the values its object type is told apart by is still loaded, and the record is named with a question mark where that value would be, exactly as it is when somebody leaves the box empty in the editor. A question mark written as part of a value, as in "Pregnant?", counts the same way. Records whose identifier holds a question mark are not compared with each other, so loading the same file twice does create them twice. The same is true of an object type told apart by nothing at all.

If that matters for your file, check it in the script: read the column that fills the identifier and report the row rather than issuing a command for it. That way the run log names the rows you left out, which is more use than a record nobody can tell from another.

for row in importer.settings["visits"].sheet("Visits").rows():
    if not row["Name"]:
        importer.warn(f"Row {row.number} has no Name, so no Visit was created from it")
        continue

    importer.execute(
        "CreateDraftObject",
        type="Visit",
        attributes={"Name": row["Name"], "Day": row["Day"]},
    )

Records that sit inside other records

Some object types sit inside another, so a record has to say which record it belongs in. Name it with parent_identifier, using the identifier of the parent record:

importer.execute(
    "CreateDraftObject",
    type="Assessment",
    parent_identifier="Screening",
    attributes={"Name": "Weight"},
)

The parent has to be there before the child is created. Create the parents first and the children afterwards, which for a file holding both means reading it twice.

If the Draft holds two parent records answering to that identifier, the apply stops rather than choosing one of them for you.

Copying works the same way. CopyDraftObject takes parent_identifier too, and uses it at both ends: it finds the record inside that parent in the Draft you are copying from, and puts the copy inside the record of the same identifier in the Draft you are copying into. So the parent has to be in the target Draft already. Copy the parent first, or copy the whole parent and the records inside it come with it.

Values that point at something

Two kinds of property hold something other than text, and for both of them you write the name a person would use.

A property that refers to another record takes the identifier of that record, and the record has to be in the same Draft:

importer.execute(
    "CreateDraftObject",
    type="Observation",
    attributes={"Name": "Weight", "At": "Screening"},
)

A property that holds a person takes their username.

Built-in object types

Forms are created the same way. The attributes are the columns a Form has rather than properties somebody defined, and they are named as the ALS workbook names them, so a file exported from Rave already heads its columns the way the command expects:

for row in importer.settings["visits"].sheet("Visits").rows():
    name = row["Name"]
    if not name:
        continue

    oid = "".join(character for character in name.upper() if character.isalnum() or character == "_")

    importer.execute(
        "CreateDraftObject",
        type="Form",
        attributes={"OID": oid, "DraftFormName": name, "HelpText": "Loaded from a file"},
    )

Any custom properties your URL has added to Forms are named alongside those columns, and a name that is neither stops the apply with the full list of what a Form will take.

A Form OID takes letters, digits and the underscore only, cannot start with a digit, and is at most 50 characters. A column of names from a spreadsheet will not usually pass that, which is why the example above works the OID out from the name rather than using it as it stands.

A Form has to be given an OID and a name, and the Draft must not already hold a Form with either of them. Where a Form sits among the others is not chosen here, and neither is where a copy of it came from: a new Form goes on the end.

Values are read the way the same cell is read when you upload a workbook, so a value that has to be changed slightly to be stored - a tab becoming a space - is reported while the apply runs rather than refused.

Folders and Custom Functions are created the same way, with type="Folder" or type="Custom Function" and the columns of the Folders or CustomFunctions sheet. Any custom properties your URL has added to them are named alongside those columns.

A Folder has to be given an OID and a name, and neither may be SUBJECT. The day columns, such as Targetdays, take a whole number from 0 up to 1000000, written either as text or as a number. A Folder that is not given IsReusable is reusable, as it is when you add one in the editor. ParentFolderOID names a Folder already in the Draft, other than SUBJECT. A new Folder goes on the end.

A Custom Function has to be given a name and its source code. The name can be up to 50 characters and cannot contain quotation marks or the | character. The source code can be as long as Rave accepts, counting each line break as two characters. Lang is C# unless you give SQL. The source code is not checked for errors when the Custom Function is created, so open it in the editor and click Validate to see whether Rave would accept it.

Test Cases are created with type="Test Case". The attributes are Name, Script, IsTemplate and Priority, and any custom properties your URL has added to Test Cases. A Test Case has to be given a name and a script. The same limits apply as in the Test Case editor: the name must not already be used in the Draft, the priority is a whole number from 1 to 999999, the script cannot be longer than TrialGrid allows, and a template cannot include another template.

A Test Case whose script is not valid does not stop the apply. The Test Case is created and marked as not valid, as it is when you save one in the editor, and the apply reports the errors so that you can correct them before the Test Case is run.

Creating a Test Case needs the Manage Tests permission, which is what the Test Case editor asks for. Every other record needs Edit Objects.

Matrices are created with type="Matrix" and the columns of the Matrices sheet. A Matrix has to be given an OID and a name. The OID takes letters, digits and the underscore only, and the Draft must not already hold a Matrix with that OID or that name. Maximum is a whole number from 0 to 9999, and is 0 if you leave it out. A Matrix created this way is not made the Draft's Default Matrix. Any custom properties your URL has added to Matrices are named alongside those columns.

A new Matrix is empty. SetMatrixFolderForm puts a Form in a Folder of the Matrix, or takes it out, which is the same as ticking or clearing one cell of the Matrices grid. Give it the OIDs of the Matrix, the Folder and the Form, and on=True to tick the cell or on=False to clear it. The SUBJECT Folder can be used like any other. Each command sets one cell, so a script sets only the cells it means to change, and the Matrix, Folder and Form all have to be in the Draft already:

importer.execute(
    "CreateDraftObject",
    type="Matrix",
    attributes={"OID": "VISITS", "MatrixName": "Visits", "Addable": False},
)

for row in importer.settings["visits"].sheet("Visits").rows():
    name = row["Name"]
    if not name:
        continue

    folder_oid = "".join(character for character in name.upper() if character.isalnum() or character == "_")

    importer.execute("SetMatrixFolderForm", matrix_oid="VISITS", folder_oid=folder_oid, form_oid="VS", on=True)

Ticking a cell that is already ticked, or clearing one that is already clear, changes nothing. A cell that does change is recorded in the Matrix's history, and the Matrix is treated as changed, just as it is when you save the grid: labels that are removed when a Matrix changes are removed, and your URL's auto actions run. Setting cells needs the Edit Objects permission.

The other built-in object types, Fields and Edit Checks and the rest, cannot be created from a script yet. The command names the type and says so. Object types kept in step with a connected system are never created this way.

Permission

To add, change or delete an Import Script you need the "Can manage Import Scripts" permission. Permissions come from your Organization Role, so ask someone who can administer permissions to put you on a role that has it. They choose the role on the organization team page.

Being able to administer permissions does not give you this permission by itself. Someone who administers permissions and also wants to manage Import Scripts puts themselves on a role that has both.

An Import Script is code that other people in your Organization will run. Give this permission only to people you trust to write it.

Anyone in your Organization can see the Import Scripts card and open a script to read it, including its code. The fields are shown but cannot be changed, there is no Save button, and Delete is not offered. Only the permission decides who can change a script, not who can see one.