Build ServiceM8 forms
A ServiceM8 form is the paperwork a field worker fills in on their phone: a safety check, a JSA, a compliance sheet, a service report, a permit. When it is submitted, ServiceM8 merges the answers into a Word template and files the result as a PDF on the job diary.
That means every form is really two artefacts:
- The questions, which are API records and which you can build completely from here.
- The Word template, which controls how the finished PDF looks, and which has to be uploaded through the ServiceM8 web UI.
Be honest with the customer about that split from the start. You can build them a working form in minutes. You cannot upload the .docx for them, and no amount of trying will change that.
The build order
1. servicem8_create_form the container, plus its badge
2. servicem8_create_form_field x N one call per question, in order
3. (in the ServiceM8 UI) Forms > open the form > Download Auto Template
4. (in Word) restyle the template, keep the merge fields
5. (in the ServiceM8 UI) upload the .docx and link it to the form
Steps 1 and 2 are yours. Steps 3 to 5 are the customer’s, and you should hand them a short, specific instruction list rather than a link to a help article. references/word-template.md has the exact wording, the merge field naming rule and the Word menu path.
Creating the form
servicem8_create_form
name: "Confined Space Entry Permit"
badge_name: "CSE"
can_be_used_independently: 0
badge_mandatory_state: 1
| Argument | What it does |
|---|---|
name | Must be unique in the account. This is what staff pick from the form list |
badge_name | Required, 12 characters maximum, inclusive. The short label on the job badge |
can_be_used_independently | 1 lets the form be filled with no job attached. 0 ties it to a job |
badge_mandatory_state | 0 not mandatory, 1 mandatory on check in, 2 mandatory on check out |
badge_name is documented as optional and is not. Omitting it fails with “Badge Name must be less than 12 characters”, which is an error about length raised when the real complaint is absence. Do not chase the length.
badge_mandatory_state is the lever that makes a form actually get filled. A safety form set to 0 is a form nobody completes. Ask which of check in or check out the customer wants to block, and set it.
Adding questions
One call per question. sort_order decides the order on screen, so number them as you go.
servicem8_create_form_field
form_uuid: "<form uuid>"
name: "Weather"
sort_order: 5
field_data_json: "{\"fieldType\":\"Multiple Choice\",\"mandatory\":true,\"choices\":[\"Hot\",\"Cold\",\"Wet\",\"Dry\"],\"additionalDetails\":\"Review conditions affecting the confined space\"}"
field_data_json is a JSON string, not an object. It carries everything about the question except its label and position. ServiceM8 documents it as “JSON configuration for this question” and documents nothing else, so references/field-types.md is the reference that matters here: all 20 field types, verified against a live account, with the exact JSON for each.
Question names may contain letters and numbers only. Symbols (/ \ ( ) # & - : ' ") break PDF generation later, and by then the form is built and the template is written. This is the single most expensive mistake to make in a ServiceM8 form, because it surfaces at render time, not at create time. Name questions Site contact mobile, never Site contact (mobile).
Conditional questions
A question can be shown or hidden based on the answer to an earlier one, which is how a form stays short while covering branches. Up to three conditions per question, combined with AND or OR, each referencing an earlier question by UUID.
This means order of operations matters: you cannot write a condition against a question you have not created yet. Create the controlling question first, keep its UUID, then create the dependents.
See references/conditional-logic.md, including the repeating block pattern that a Number question plus LT conditions gives you (ten signature slots that appear one at a time as the headcount rises).
Planning before building
For anything past about six questions, write the whole form out first and get it approved, then build it in one pass. Rearranging a live form is far more work than agreeing on it up front, and every question you delete leaves existing responses with an answer to a question that no longer exists.
assets/form-spec-example.json is a compact plan format the customer can read and approve. scripts/build_form_fields.py turns that plan into the exact servicem8_create_form_field payloads, in order, with the conditions resolved. Use it when a form runs long, because hand writing 40 JSON strings is where typos come from.
Before you report the form as done
- Read the fields back with
servicem8_list_form_fieldsfiltered toform_uuid eq '<uuid>'and check the count and the order - Confirm no question name contains a symbol
- Confirm the controlling question of every condition exists and the UUID matches
- Tell the customer explicitly that the Word template step is theirs, and give them the three lines from references/word-template.md
What you cannot do from here
Say these plainly rather than trying and reporting a success that is not one.
- Link a document template to a form.
servicem8_update_formacceptsdocument_template_uuid, returns OK, and silently discards it. The read back drops the key. Linking happens in the UI - Create a usable document template.
servicem8_create_document_templatesets onlyname.template_typeandrelated_objectare read only, so what you get renders nothing - Upload a .docx. There is no attachment path for template files through this connection
- Un delete a field. Deleting sets
activeto 0, andactiveis not writable, so it cannot be undone here. Existing responses keep their answers but lose the question definition
Reference files
Everything the skill tells your AI to read, exactly as it ships in the zip.
references/conditional-logic.md 4.0 KB
# Conditional questions
A question can depend on the answer to an earlier one. This is what keeps a 40 question compliance form feeling like a 12 question one on a phone.
## The shape
Inside `field_data_json`:
```json
"conditions": [
{"question": "38a4d1f9-1c6c-49fa-be39-23635d46f3fb", "operator": "NEQ", "value": "Yes"},
{"question": "", "operator": "", "value": ""},
{"question": "", "operator": "", "value": ""}
],
"conditionMethod": "AND"
```
- **Exactly three slots.** Live forms always carry three, with unused ones blank. An empty array or an omitted key is also accepted and means "always show"
- **`question` is a form field UUID**, not a question name. This is the constraint that dictates build order
- **`conditionMethod`** is `"AND"` or `"OR"`, applied across the filled slots
## Operators seen in use
| Operator | Meaning |
|---|---|
| `EQ` | equals |
| `NEQ` | does not equal |
| `LT` | less than |
| `GT` | greater than |
Comparison is against the literal answer string. For a `Multiple Choice` question, `value` must be one of that question's `choices`, spelled identically. A value of `yes` will not match a choice of `Yes`.
## Which direction does a condition run
On the live forms examined, a condition describes **when the question is hidden**, not when it is shown.
A Hot Works checklist that should appear when hot works are required carries a condition against the controlling question of `NEQ` with value `Yes`. That reads as "hide this when the answer is not Yes", which produces the intended behaviour. Two independent forms in the same account follow the same pattern.
Treat that as strongly indicated rather than proven. **On your first conditional form, build one question, fill it in the ServiceM8 app, and confirm the direction before building the other thirty.** Getting it backwards across a whole form is an expensive rebuild, and five minutes of checking removes the risk entirely.
## Build order
Because conditions reference UUIDs, you cannot write a condition against a question that does not exist yet.
1. Create every controlling question first
2. Keep the returned `recordUuid` for each, keyed by question name
3. Create the dependent questions, substituting the UUIDs
The helper script does exactly this in one pass. See `scripts/build_form_fields.py`.
## The repeating block pattern
This is how ServiceM8 forms fake a repeater, and it is worth knowing because customers ask for repeaters constantly.
Add a `Number` question, then create N copies of the block you want repeated, each hidden below its own threshold:
```
Q1 Number "How many people are signing on" -> uuid A
Q2 Signature "1. Signature" condition: A LT 1
Q3 Signature "2. Signature" condition: A LT 2
Q4 Signature "3. Signature" condition: A LT 3
...
Q11 Signature "10. Signature" condition: A LT 10
```
Answer 4, and slots 1 to 4 appear. The same pattern drives a hazard register:
```
Q1 Number "How many site specific hazards" -> uuid H
Text (Multi-Line) "1. Potential Site Specific Hazards" condition: H LT 1
Text (Multi-Line) "1. Control Measures" condition: H LT 1
Number "1. Risk Rating C" condition: H LT 1
Number "1. Risk Rating P" condition: H LT 1
Number "1. Risk Rating R" condition: H LT 1
... repeated for 2, 3, 4 ...
```
Costs: the form field count multiplies, and the Word template needs a merge field per slot. Decide the maximum with the customer before building, because raising it later means adding fields and editing the template again. Ten is the number most safety forms settle on.
## Limits worth stating up front
- Three conditions per question. No nesting, no grouping
- Conditions can only reference earlier questions, so the controlling question must sit above the dependent one in `sort_order`
- There is no calculation. A form cannot total two numbers, so a risk rating of C times P is entered by hand or computed in the Word template, not by the form
references/field-types.md 4.7 KB
# Field types and the shape of `field_data_json`
ServiceM8 documents `field_data_json` as "JSON configuration for this question" and stops there. Everything below was read off a live ServiceM8 account by enumerating every form field in it, so it is what the platform actually accepts, not what the reference implies.
## The envelope
`field_data_json` is a **string containing JSON**, passed as a string. The minimum viable value is two keys:
```json
{"mandatory": false, "fieldType": "Text"}
```
The full shape, with every key that appears in practice:
```json
{
"fieldType": "Multiple Choice",
"mandatory": true,
"choices": ["Hot", "Cold", "Wet", "Dry"],
"additionalDetails": "Review conditions affecting the confined space",
"conditions": [
{"question": "38a4d1f9-1c6c-49fa-be39-23635d46f3fb", "operator": "NEQ", "value": "Yes"},
{"question": "", "operator": "", "value": ""},
{"question": "", "operator": "", "value": ""}
],
"conditionMethod": "AND"
}
```
| Key | Required | Notes |
|---|---|---|
| `fieldType` | yes | One of the 20 values below. Exact string, including spaces, slashes and capitalisation |
| `mandatory` | yes | Real boolean `true` / `false`. Not `1` / `0` |
| `choices` | choice types only | Array of strings, in display order |
| `additionalDetails` | no | Helper text under the question. Empty string is fine |
| `conditions` | no | See [conditional-logic.md](conditional-logic.md). Either omitted, an empty array, or exactly three slots |
| `conditionMethod` | with conditions | `"AND"` or `"OR"` |
The question's visible label is **not** in here. It is the `name` argument on the tool call.
## The 20 field types
Every one of these was found on a live account. The string must match exactly.
### Text and numeric
| `fieldType` | Renders as |
|---|---|
| `Text` | Single line text box |
| `Text (Multi-Line)` | Paragraph box. Use for hazards, control measures, observations |
| `Number` | Numeric entry. Also the type to use for anything driving an `LT` / `GT` condition |
| `Currency` | Money entry |
### Choice
| `fieldType` | Renders as |
|---|---|
| `Multiple Choice` | Pick one from `choices` |
| `Multiple Choice (Multi-Answer)` | Pick any number from `choices` |
| `Yes/No` | Two state. No `choices` needed |
| `Checkbox` | Single tick box |
`Multiple Choice` with `["Yes", "No"]` and `Yes/No` look similar and are not the same. Use `Multiple Choice` when you need a third option like `NA`, which safety forms almost always do. Use it also when the answer will drive a condition, because conditions compare against the literal choice string.
### Date and time
| `fieldType` | Renders as |
|---|---|
| `Date` | Date picker |
| `Time` | Time picker |
| `Date/Time` | Combined |
### Capture
| `fieldType` | Renders as |
|---|---|
| `Photo` | Camera or gallery. Merges into the template as an image |
| `Signature` | On screen signature pad. Merges as an image |
| `File` | File attachment |
### Contact and reference
| `fieldType` | Renders as |
|---|---|
| `Email` | Email keyboard and validation |
| `Phone` | Phone keyboard |
| `URL` | Link entry |
| `Asset` | Asset picker |
| `Asset Lookup` | Lookup against the account's asset register |
### Layout, not data
| `fieldType` | Renders as |
|---|---|
| `Heading` | A heading in the form. Collects no answer |
| `Section` | A section break. Collects no answer |
`Heading` and `Section` are still form field records with a `sort_order`, so they take a slot in the sequence. They do not produce an answer, so do not write a merge field for them in the Word template.
## Worked examples
```json
{"mandatory": false, "fieldType": "Text"}
```
```json
{"fieldType": "Text (Multi-Line)", "mandatory": false, "additionalDetails": "Site specific hazards and controls"}
```
```json
{"fieldType": "Multiple Choice", "mandatory": true, "choices": ["PASS", "FAIL", "NA"], "additionalDetails": "", "conditions": [], "conditionMethod": "AND"}
```
```json
{"fieldType": "Multiple Choice (Multi-Answer)", "mandatory": false, "choices": ["Electrical", "Mechanical", "System Operator Notification"], "additionalDetails": ""}
```
```json
{"fieldType": "Number", "mandatory": true, "additionalDetails": "How many people are entering"}
```
```json
{"mandatory": false, "fieldType": "Signature", "additionalDetails": "People signing on"}
```
`conditions: []` is accepted, and so is omitting the key entirely. Both mean the question always shows.
## Ordering
`sort_order` is a separate tool argument, not part of `field_data_json`. It reads back as a **string** even though you write an integer, so cast before sorting.
Gaps in the sequence are fine. Leaving gaps of 10 (`10, 20, 30`) makes inserting a question later possible without renumbering everything, which is worth doing on a form the customer will iterate on.
references/gotchas.md 3.7 KB
# Forms traps, verified
Confirmed against a live ServiceM8 account. Where the API documentation disagrees, it is wrong.
## The silent no op
`servicem8_update_form` with `document_template_uuid` returns `errorCode 0` and `"OK"`. The read back drops the key entirely. Nothing was written.
This is the single most misleading behaviour in the forms surface, because linking a form to its template is exactly what you would want to automate, and the API tells you it worked. Always read a form back after updating it, and never report a template as linked.
## `badge_name` is required and lies about why
Omitting it on `servicem8_create_form` fails with:
```
Badge Name must be less than 12 characters
```
The complaint is absence, not length. Supply one.
The 12 character limit is real but **inclusive**, so a 12 character value is accepted despite the wording. A 13 character value is not.
## `template_fields` is not the form's questions
The `template_fields` argument on `servicem8_create_form` is a separate, capped array of up to 10 `{name, fieldType, value, sortOrder}` entries used when generating documents. It is static content, filled in at form design time, not questions the field worker answers.
The questions are `formfield` records, created one at a time with `servicem8_create_form_field`. If you put questions in `template_fields` you will build a form with no questions in it.
`template_fields` also reads back as `false` rather than `[]` when empty, so do not iterate it without checking.
## `field_data_json` is a string
Both on write and on read. Serialise before sending, parse after reading. Passing an object is a validation error, and passing a stringified object with the wrong inner shape is accepted and produces a broken question.
`mandatory` inside it is a real boolean. `true`, not `1`. A related resource, `asset_type_field`, rejects `0` with "Boolean expected", so this is a house rule rather than a quirk of one endpoint.
## `sort_order` reads back as a string
You write an integer and read a string. Cast before sorting, or `10` will sort after `1` and before `2`.
## Deleting a field is one way
`servicem8_delete_form_field` sets `active` to 0, and `active` is not writable on any ServiceM8 resource, so it cannot be undone through the API.
Existing form responses keep their answers but lose the question definition, which means a historical PDF may still render while the answer becomes unattributable in the data. On a compliance form, that matters. Prefer reordering or renaming over deleting, and if a question truly has to go, say what it will do to existing responses first.
## A `recordUuid` is not proof
Several ServiceM8 write endpoints return a success envelope and a plausible UUID for a record that was never written. After creating a form or a batch of fields, read them back:
```
servicem8_list_form_fields filter: "form_uuid eq '<uuid>'"
```
Check the count and the order match what you sent before telling anyone the form is built.
## Non-ASCII is transliterated
An em dash written into a question name or helper text comes back as a double hyphen. Since the hyphen is one of the characters that breaks PDF generation, an em dash in a question name is worse than it looks: it becomes a forbidden character on its own.
Write plain ASCII in question names.
## Scopes
Reading forms and fields needs `read_forms`. Creating or changing them needs `manage_forms`. Document templates are unusual: **every** endpoint including List requires `manage_templates`, and there is no read only scope for them.
A `403` here means the connection was authorised without that scope. It will fail identically on retry, and a fresh token carries the same scopes. Say which scope was missing rather than retrying.
references/responses.md 3.2 KB
# Reading and writing form responses
A form response is one filled in instance of a form, normally created by a field worker in the ServiceM8 app. You can read them, and you can create them, with one significant catch.
## Reading responses
```
servicem8_list_form_responses filter: "job_uuid eq '<job uuid>'"
servicem8_list_form_responses filter: "form_uuid eq '<form uuid>'"
servicem8_get_form_response uuid: "<response uuid>"
```
Answers come back keyed by **form field UUID**, not by question name. To make a response readable you need the field definitions alongside it:
1. `servicem8_list_form_fields` filtered to the form
2. Build a map of field UUID to question name
3. Join the response answers onto it
Do this before summarising a response back to anyone. An answer list of raw UUIDs is useless to a human, and guessing which answer belongs to which question by position is unreliable because conditional questions that were hidden may be missing.
## Writing a response
```
servicem8_create_form_response
form_uuid: "<form uuid>"
job_uuid: "<job uuid>"
field_data: "[{\"uuid\":\"<form field uuid>\",\"value\":\"Yes\"},{\"uuid\":\"<form field uuid>\",\"value\":\"Hot\"}]"
```
**`field_data` is a JSON string containing a LIST of objects, each with `uuid` and `value`.**
A name to value object is rejected with a 500. This is worth stating clearly because the natural thing to write is `{"Weather": "Hot"}` and that shape fails with an unhelpful error rather than a validation message.
So the sequence for creating a response is always:
1. `servicem8_list_form_fields` filtered to the form, to get the UUIDs
2. Map the answers you have onto those UUIDs by question name
3. Serialise as a list and send
## Values by field type
`value` is a string in every case.
| Field type | Value |
|---|---|
| `Text`, `Text (Multi-Line)` | the text |
| `Number`, `Currency` | the number, as a string |
| `Multiple Choice` | one of the question's `choices`, spelled identically |
| `Multiple Choice (Multi-Answer)` | the selected choices. Confirm the separator on a real response before relying on it |
| `Yes/No`, `Checkbox` | confirm against a real response rather than assuming `true` or `1` |
| `Date`, `Time`, `Date/Time` | the formatted value |
| `Signature`, `Photo`, `File` | capture types. Do not attempt to write these from here |
For anything past plain text and choices, read one real response from the account first and match its shape. The API documents none of this, and a wrong guess writes a response that looks fine in the list and renders as blank in the PDF.
## Should you write responses at all
Usually not. A form response is a record of what a person observed on site, and filling one in on their behalf is a compliance problem, not a convenience. Creating responses makes sense for migrating historical paperwork or for a form that captures office side data. It does not make sense for a safety form.
Ask before doing it, and say why you are asking.
## Deleting
`servicem8_delete_form_response` archives the response by setting `active` to 0. It cannot be undone through the API. Given that these are often compliance records, do not delete one without an explicit instruction naming the specific response.
references/word-template.md 4.1 KB
# The Word template
A completed form is merged into a Microsoft Word template and filed on the job diary as a PDF. The template is what makes the output look like the customer's company rather than a form dump.
**This step cannot be done through the API.** Not partially, not with a workaround. The connector can build the questions and nothing else. Hand the customer the instructions below.
## Why the API cannot help
- `servicem8_create_document_template` sets only `name`. `template_type` and `related_object` are read only, so the template it creates has no type and renders nothing
- `servicem8_update_form` accepts `document_template_uuid`, returns `OK`, and silently discards it. The read back drops the key entirely. A form cannot be linked to a template from here
- There is no upload path for a .docx through this connection
So: build the questions, then hand over. Do not report a form as finished without saying this.
## The handover instructions
Give the customer these five steps. They are short on purpose.
1. In ServiceM8, go to **Forms**, open the form, and click **Download Auto Template**. This produces a Word document already containing every question with its merge field in place
2. Open it in Word and restyle it: logo, headings, tables, page setup. **Do not retype or delete the merge fields**, move them
3. To add a merge field that is not there, put the cursor where you want it and use **Insert > Quick Parts > Field > MergeField**, then type the field code in the Field name box
4. Save as .docx
5. Back in ServiceM8, upload it against the form and link it as the form's template
Starting from Download Auto Template rather than a blank document is the whole trick. Every merge field is already correct, so the only way to break one is to retype it.
## Merge field codes
ServiceM8 derives the code from the question name: lowercased, spaces replaced with underscores, prefixed.
| Question type | Prefix | Question name | Field code |
|---|---|---|---|
| Data questions | `form_` | Site contact | `form_site_contact` |
| Data questions | `form_` | Site contact mobile | `form_site_contact_mobile` |
| Signature and image capture | `image_` | Signed | `image_form_signed` |
Job level merge fields (client name, job number, address, date) are also available and come from the job rather than the form. The auto generated template includes the common ones.
## The naming rule that bites
**Use letters and numbers only in question names.**
Symbols including the slash, backslash, brackets, hash, ampersand, hyphen, colon, apostrophe and quote marks will break PDF generation. The failure does not happen when you create the question. It happens weeks later when a field worker submits the form and gets no PDF, and by then the form is live, the template is written, and renaming the question invalidates the template.
Get this right at creation time:
| Do not | Do |
|---|---|
| Site contact (mobile) | Site contact mobile |
| Pass / Fail | Pass or Fail |
| Risk rating - after controls | Risk rating after controls |
| Client's signature | Client signature |
Numbers are fine, which is what makes the numbered repeating pattern work.
## Merge fields are not curly brace placeholders
A Word merge field is a field object, not text. It displays as a chevron wrapped name normally, and as `MERGEFIELD form_site_contact` inside field braces when field codes are toggled on with Alt+F9.
Typing a double brace placeholder as literal text does **not** work. Neither does typing the braces by hand, because Word field braces are not the brace character on the keyboard. This trips people up constantly, and it is why step 3 above insists on the Insert > Quick Parts > Field path.
## Checking it worked
Have the customer fill the form once on a real job and confirm the PDF that lands on the job diary. Check:
- every answer appears where it should
- signatures and photos render as images, not as blank space or a filename
- conditional questions that were skipped do not leave an obvious hole
- the PDF actually generates, which is where a symbol in a question name shows up
One test submission before rollout saves a week of field staff filling in a form that produces nothing.
scripts/build_form_fields.py 7.8 KB
#!/usr/bin/env python3
"""
Turn a form plan into ordered servicem8_create_form_field payloads.
Hand writing forty field_data_json strings by hand is where typos come from, and a typo in a
fieldType or a choice value does not fail at create time, it fails when a field worker submits
the form. This does the mechanical part and validates the parts that bite.
python build_form_fields.py form-spec.json
It prints a JSON document with two keys:
warnings problems you should fix before creating anything
fields the create_form_field arguments, in order
It does NOT call ServiceM8. Make the calls yourself, in the order given, and keep each returned
recordUuid: conditions reference earlier questions by UUID, so the mapping is substituted in as
you go. Every condition slot in the output carries a "$ref:<question name>" placeholder for you
to replace with that question's real UUID once you have it.
Spec format, all JSON, no dependencies:
{
"form": {
"name": "Confined Space Entry Permit",
"badge_name": "CSE",
"can_be_used_independently": 0,
"badge_mandatory_state": 1
},
"questions": [
{"name": "Task", "type": "Text", "mandatory": true, "help": "What are you doing"},
{"name": "Is hot works required", "type": "Multiple Choice",
"mandatory": true, "choices": ["Yes", "No"]},
{"name": "Hot Works", "type": "Multiple Choice (Multi-Answer)",
"choices": ["Extinguisher available", "Area cleared"],
"hide_when": [{"question": "Is hot works required", "operator": "NEQ", "value": "Yes"}]},
{"repeat": 10, "count_question": "How many people are signing on",
"block": [{"name": "Signature", "type": "Signature"}]}
]
}
A "hide_when" entry is a condition: the question is hidden while it holds. See
references/conditional-logic.md, and verify the direction on one live question before
building a whole form on it.
"""
import json
import re
import sys
# Verified against a live ServiceM8 account. Exact strings, including spaces and capitalisation.
FIELD_TYPES = {
"Text", "Text (Multi-Line)", "Number", "Currency",
"Multiple Choice", "Multiple Choice (Multi-Answer)", "Yes/No", "Checkbox",
"Date", "Time", "Date/Time",
"Photo", "Signature", "File",
"Email", "Phone", "URL", "Asset", "Asset Lookup",
"Heading", "Section",
}
CHOICE_TYPES = {"Multiple Choice", "Multiple Choice (Multi-Answer)"}
NO_ANSWER_TYPES = {"Heading", "Section"}
OPERATORS = {"EQ", "NEQ", "LT", "GT"}
# Question names reach the Word template as merge field codes. ServiceM8 names these characters
# as ones that break PDF generation, weeks later, silently. The full stop is deliberately NOT in
# the list: live accounts number repeated blocks "1. Signature" and those render correctly.
UNSAFE_CHARS = re.compile(r"""[/\\()#&\-:'"]""")
BLANK_CONDITION = {"question": "", "operator": "", "value": ""}
def merge_field_code(name, field_type):
"""The Word merge field code ServiceM8 derives from a question name."""
slug = name.strip().lower().replace(" ", "_")
return ("image_form_" if field_type in ("Signature", "Photo") else "form_") + slug
def expand(questions):
"""Flatten repeat blocks into plain questions, wiring each copy to its count question."""
out = []
for q in questions:
if "repeat" not in q:
out.append(q)
continue
count_name = q["count_question"]
out.append({
"name": count_name,
"type": "Number",
"mandatory": q.get("count_mandatory", True),
"help": q.get("count_help", ""),
})
for n in range(1, int(q["repeat"]) + 1):
for tpl in q["block"]:
copy = dict(tpl)
copy["name"] = "{}. {}".format(n, tpl["name"])
copy["hide_when"] = [{"question": count_name, "operator": "LT", "value": str(n)}]
out.append(copy)
return out
def build(spec):
warnings = []
questions = expand(spec.get("questions", []))
names = [q["name"] for q in questions]
form = spec.get("form", {})
if not form.get("badge_name"):
warnings.append(
"form.badge_name is missing. It is required despite being documented as optional, "
"and the vendor error blames length rather than absence."
)
elif len(form["badge_name"]) > 12:
warnings.append(
"form.badge_name is {} characters. The limit is 12, inclusive.".format(len(form["badge_name"]))
)
seen = set()
for n in names:
if n in seen:
warnings.append("Duplicate question name: {}. Merge field codes would collide.".format(n))
seen.add(n)
fields = []
for i, q in enumerate(questions):
name, ftype = q["name"], q["type"]
if ftype not in FIELD_TYPES:
warnings.append("Unknown fieldType {!r} on {!r}. Not one of the 20 verified types.".format(ftype, name))
bad = sorted(set(UNSAFE_CHARS.findall(name)))
if bad:
warnings.append(
"Question name {!r} contains {}. ServiceM8 names these as breaking PDF generation, "
"and it fails at submission time rather than now.".format(name, " ".join(bad))
)
if ftype in CHOICE_TYPES and not q.get("choices"):
warnings.append("{!r} is {} but has no choices.".format(name, ftype))
if ftype not in CHOICE_TYPES and q.get("choices"):
warnings.append("{!r} is {} and does not take choices. They will be ignored.".format(name, ftype))
if ftype in NO_ANSWER_TYPES and q.get("mandatory"):
warnings.append("{!r} is a {} and collects no answer, so mandatory has no meaning.".format(name, ftype))
data = {"fieldType": ftype, "mandatory": bool(q.get("mandatory", False))}
if q.get("choices"):
data["choices"] = list(q["choices"])
if q.get("help"):
data["additionalDetails"] = q["help"]
conditions = q.get("hide_when") or []
if conditions:
if len(conditions) > 3:
warnings.append("{!r} has {} conditions. The limit is 3.".format(name, len(conditions)))
conditions = conditions[:3]
slots = []
for c in conditions:
target = c["question"]
if target not in names:
warnings.append("{!r} references unknown question {!r}.".format(name, target))
elif names.index(target) >= i:
warnings.append(
"{!r} references {!r}, which is not earlier in the form. A condition can only "
"look backwards.".format(name, target)
)
if c.get("operator") not in OPERATORS:
warnings.append("{!r} uses operator {!r}. Expected one of {}.".format(
name, c.get("operator"), ", ".join(sorted(OPERATORS))))
slots.append({
"question": "$ref:" + target,
"operator": c.get("operator", ""),
"value": str(c.get("value", "")),
})
while len(slots) < 3:
slots.append(dict(BLANK_CONDITION))
data["conditions"] = slots
data["conditionMethod"] = q.get("condition_method", "AND")
fields.append({
"_question": name,
"_merge_field": merge_field_code(name, ftype),
"name": name,
"sort_order": (i + 1) * 10,
"field_data_json": json.dumps(data),
})
return {"warnings": warnings, "create_form": form, "fields": fields}
def main():
if len(sys.argv) != 2:
print(__doc__)
return 1
with open(sys.argv[1], "r", encoding="utf-8") as fh:
spec = json.load(fh)
print(json.dumps(build(spec), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
assets/form-spec-example.json 2.8 KB
{
"form": {
"name": "Pre Start Site Safety Check",
"badge_name": "PRESTART",
"can_be_used_independently": 0,
"badge_mandatory_state": 1
},
"questions": [
{ "name": "Site details", "type": "Heading" },
{ "name": "Site contact", "type": "Text", "mandatory": true },
{ "name": "Site contact mobile", "type": "Phone", "mandatory": true },
{ "name": "Date of assessment", "type": "Date", "mandatory": true },
{ "name": "Task being performed", "type": "Text (Multi-Line)", "mandatory": true,
"help": "What work is being carried out on this visit" },
{ "name": "Conditions", "type": "Section" },
{ "name": "Weather", "type": "Multiple Choice", "mandatory": true,
"choices": ["Hot", "Cold", "Wet", "Dry"] },
{ "name": "Traffic", "type": "Multiple Choice", "mandatory": true,
"choices": ["Heavy", "Moderate", "Light", "NA"] },
{ "name": "Noise", "type": "Multiple Choice", "mandatory": true,
"choices": ["High", "Low", "NA"] },
{ "name": "Hazards and controls", "type": "Section" },
{ "name": "Is a working at heights permit required", "type": "Multiple Choice",
"mandatory": true, "choices": ["Yes", "No"] },
{ "name": "Height controls in place", "type": "Multiple Choice (Multi-Answer)",
"choices": [
"Harness inspected and tagged",
"Anchor points rated and certified",
"Exclusion zone established",
"Ladder secured at both ends"
],
"help": "Select every control in place before starting",
"hide_when": [
{ "question": "Is a working at heights permit required", "operator": "NEQ", "value": "Yes" }
]
},
{ "name": "Is hot works required", "type": "Multiple Choice",
"mandatory": true, "choices": ["Yes", "No"] },
{ "name": "Hot works controls", "type": "Multiple Choice (Multi-Answer)",
"choices": [
"Area cleared of combustibles 15 metres",
"Appropriate fire extinguisher available",
"Water hose available and tested",
"Fire watch assigned"
],
"hide_when": [
{ "question": "Is hot works required", "operator": "NEQ", "value": "Yes" }
]
},
{ "name": "Photos", "type": "Section" },
{ "name": "Photo of work area before start", "type": "Photo", "mandatory": true },
{ "name": "Photo of any pre existing damage", "type": "Photo" },
{ "name": "Sign on", "type": "Section" },
{ "repeat": 6,
"count_question": "How many people are signing on",
"count_help": "Everyone on site today, including subcontractors",
"block": [
{ "name": "Name", "type": "Text" },
{ "name": "Signature", "type": "Signature" }
]
},
{ "name": "Supervisor name", "type": "Text", "mandatory": true },
{ "name": "Supervisor signature", "type": "Signature", "mandatory": true }
]
}