SG Ground Truth

status picker

List the statuses a project actually offers, each with the label, colour and icon needed to draw it

  • status
  • icon
  • colour
  • schema
  • list-field
  • entity-field
  • dotted-field
  • project
  • cache
  • Markdown
  • Source
API

Anything that shows Flow Production Tracking data to a person draws a status. Four pieces make one: the codes the project offers, the label for each, the colour, and the icon. The first two come from the field schema read with project_id, the last two from the Status row and the Icon behind it. Two calls to the REST API cover all four. The stock icons are not in the API at all and are rediscovered from the site's own stylesheet.

Call

import json
import re
import sys
from functools import lru_cache

import requests

sys.path.insert(0, "src")                       # or PYTHONPATH=src
from sg_groundtruth.client import FPT           # adds the bearer token and the /api/v1 prefix
from sg_groundtruth.env import load

c = FPT.from_env(load("."))                     # FPT_API_SITE_URL, FPT_API_SCRIPT_NAME, FPT_API_API_KEY
ARR = {"Content-Type": "application/vnd+shotgun.api3_array+json"}   # array filters need it (probe 004)

PROJECT = 70                                    # the caller supplies these three
ENTITY_TYPE = "Version"
FIELD = "sg_status_list"

# `url` reads as an empty string unless `image_data` is asked for in the same call, so ask for both.
ICON = ("display_type", "image_map_key", "html", "url", "image_data")
FIELDS = "code,name,bg_color," + ",".join(f"icon.Icon.{f}" for f in ICON)
GREY = "204,204,204"                            # bg_color is null on some Status rows


def fail(r):
    raise SystemExit(r.text)                    # never truncate an error body


def usable(entity_type, field, project_id):
    """The codes the project offers, in the schema's own order, and the labels for them.

    `project_id` is what makes this a project answer: without it `hidden_values` is empty and the
    picker offers statuses the project's interface refuses (probe 009).
    """
    r = c.get(f"/schema/{entity_type}/fields/{field}", params={"project_id": project_id})
    if not r.ok:
        fail(r)
    p = r.json()["data"]["properties"]
    valid, hidden = p["valid_values"]["value"], p["hidden_values"]["value"]
    return [v for v in valid if v not in hidden], p["display_values"]["value"]


@lru_cache(maxsize=1)
def stylesheets():
    """The web app's own CSS, concatenated. 771416 bytes on the probed site, so fetch it once.

    The sprite is not in the REST API. `image_map_key` is a CSS class in a stylesheet the site names
    in its own root page, and the URL that rule points at ends in a per-release hash. Rediscover
    both rather than hardcoding either. Neither fetch needs an Authorization header.
    """
    root = requests.get(c.site, timeout=30)
    if not root.ok:
        return ""
    hrefs = re.findall(r'href=["\']([^"\']+\.css[^"\']*)["\']', root.text, re.I)
    out = []
    for href in hrefs:
        s = requests.get(href if href.startswith("http") else f"{c.site}{href}", timeout=60)
        if s.ok:
            out.append(s.text)
    return "\n".join(out)


def sprite(image_map_key):
    """The crop for one stock icon: the sheet, the offset into it, and the size to take.

    None when the rule is not found, which is the signal to fall back to the colour.
    """
    m = re.search(r"\.%s\b[^{}]*\{([^{}]*)\}" % re.escape(image_map_key), stylesheets())
    if not m:
        return None
    decl = m.group(1)
    href = re.search(r"url\(\s*['\"]?([^'\")]+)", decl)
    offset = re.search(r"(-?\d+)px\s+(-?\d+)px", decl)
    size = re.search(r"width:\s*(\d+)px.*?height:\s*(\d+)px", decl, re.S)
    if not (href and offset and size):
        return None
    return {"kind": "sprite", "url": href.group(1),          # join to the site root to fetch
            "offset": [int(offset.group(1)), int(offset.group(2))],
            "size": [int(size.group(1)), int(size.group(2))]}


def picker(entity_type, field, project_id):
    """Every status the project offers, each ready to draw."""
    codes, labels = usable(entity_type, field, project_id)
    # One call for all of them: `icon` is an entity link, and a dotted path through it returns the
    # Icon's own columns flattened into `attributes` (probe 003). Undotted, `icon` is
    # {id, name, type} and the renderings cost a second call over /entity/icons.
    r = c.post("/entity/statuses/_search", headers=ARR,
               json={"filters": [["code", "in", codes]], "fields": FIELDS,
                     "page": {"size": 200}})
    if not r.ok:
        fail(r)
    by_code = {x["attributes"]["code"]: x["attributes"] for x in r.json()["data"]}
    out = []
    for code in codes:                          # schema order is the order the picker shows
        a = by_code.get(code, {})
        display = a.get("icon.Icon.display_type")
        if display == "image_map":              # a stock icon, addressed by its CSS class
            icon = sprite(a["icon.Icon.image_map_key"])
        elif display == "image":                # a custom upload, already inline
            icon = {"kind": "data_uri", "uri": (a.get("icon.Icon.url") or "").replace("\n", "")}
        elif display == "html":                 # a text badge, no image at all
            icon = {"kind": "text", "text": a.get("icon.Icon.html")}
        else:                                   # no Status row for this code
            icon = None
        out.append({"code": code,
                    "label": labels.get(code, code),         # a missing key falls back to the code
                    "color": a.get("bg_color") or GREY,      # rgb(<color>) draws the badge alone
                    "icon": icon})
    return out


def show(entity_type, field, project_id):
    options = picker(entity_type, field, project_id)
    print(f"{len(options)} statuses for {entity_type}.{field} on project {project_id}")
    for o in options:
        icon = dict(o["icon"] or {"kind": "none"})
        if icon.get("kind") == "data_uri":       # never print the blob
            icon["uri"] = f"{icon['uri'][:32]}...({len(icon['uri'])} chars)"
        if icon.get("kind") == "sprite":
            icon["url"] = icon["url"].split("?")[0] + "?<release hash>"
        print(f"  {o['code']:<8} {o['label']:<22} rgb({o['color']:<11}) {json.dumps(icon)}")
    return options


options = show(ENTITY_TYPE, FIELD, PROJECT)
show("HumanUser", "sg_status_list", PROJECT)     # the third branch, on this site's user statuses

# A stored value is not a member of that list. `hidden_values` is not a subset of `valid_values`,
# and a hidden code writes and reads back at 200 (`field_types/status_list`), so read the label off
# the whole vocabulary and let the picker restrict only what a person may pick.
r = c.get(f"/schema/{ENTITY_TYPE}/fields/{FIELD}", params={"project_id": PROJECT})
labels = r.json()["data"]["properties"]["display_values"]["value"]
offered = {o["code"] for o in options}
for stored in ("apr", "pndl", None):
    print(f"  stored {str(stored):<6} label {labels.get(stored, stored)!r:<24} "
          f"offered by the picker: {stored in offered}")

Response

On the probed site, project 70 hides pndl and pndvs of Version's 16 codes, and HumanUser is the one type whose statuses reach the html branch. The status codes and labels below are that site's vocabulary, read rather than assumed.

14 statuses for Version.sg_status_list on project 70
  na       N/A                    rgb(204,204,204) {"kind": "sprite", "url": "/images/sg_icon_image_map.png?<release hash>", "offset": [-46, 0], "size": [7, 6]}
  rev      Pending Review         rgb(149,227,167) {"kind": "sprite", ... "offset": [-314, -23], "size": [12, 13]}
  vwd      Viewed                 rgb(146,146,146) {"kind": "sprite", ... "offset": [-128, 0], "size": [7, 8]}
  apr      Approved               rgb(179,179,179) {"kind": "sprite", ... "offset": [-89, -11], "size": [12, 11]}
  custom   CustomIcon             rgb(204,204,204) {"kind": "data_uri", "uri": "data:image/png;base64,iVBORw0KGg...(978 chars)"}
  fin      Final                  rgb(150,150,150) {"kind": "sprite", ... "offset": [-128, 0], "size": [7, 8]}
  ip       In Progress            rgb(202,225,202) {"kind": "sprite", ... "offset": [-332, 0], "size": [10, 10]}
  clsd     Closed                 rgb(150,150,150) {"kind": "sprite", ... "offset": [-128, 0], "size": [7, 8]}
  cmpt     Complete               rgb(146,146,146) {"kind": "sprite", ... "offset": [-337, -79], "size": [14, 14]}
  cfrm     Confirmed              rgb(161,236,154) {"kind": "sprite", ... "offset": [-352, -586], "size": [16, 16]}
  pndad    Pending Art Director   rgb(246,155,12 ) {"kind": "sprite", ... "offset": [-144, -586], "size": [16, 16]}
  part     partial                rgb(203,243,23 ) {"kind": "sprite", ... "offset": [-330, -538], "size": [16, 16]}
  pass     pass                   rgb(204,204,204) {"kind": "sprite", ... "offset": [-89, -11], "size": [12, 11]}
  pndng    Pending                rgb(150,150,150) {"kind": "sprite", ... "offset": [-64, -602], "size": [16, 16]}
2 statuses for HumanUser.sg_status_list on project 70
  act      Active                 rgb(25,118,27  ) {"kind": "text", "text": "Active"}
  dis      Disabled               rgb(204,0,1    ) {"kind": "sprite", ... "offset": [-46, 0], "size": [7, 6]}
  stored apr    label 'Approved'               offered by the picker: True
  stored pndl   label 'Pending Lead'           offered by the picker: False
  stored None   label None                     offered by the picker: False

rgb(204,204,204) is the fallback, not a value the API returned: on the probed site bg_color is null on 5 of 32 Status rows, custom, na, pass, ready and recd among them.

The rediscovery, from the same run of probe 038, every fetch made without an Authorization header:

GET /                                              200  16738 bytes   text/html
  stylesheets named in the page:
    /dist/production/stylesheets/css_reset.css?4eed9fe
    /dist/production/stylesheets/ext_all.css?4eed9fe
    /dist/production/stylesheets/login.css?4eed9fe
GET /dist/production/stylesheets/css_reset.css     200  10787 bytes   text/css
GET /dist/production/stylesheets/ext_all.css       200  62522 bytes   text/css
GET /dist/production/stylesheets/login.css         200  771416 bytes  text/css
  div.icon_apr {width: 12px; height: 11px;
                background: transparent url(/images/sg_icon_image_map.png?<32 hex chars>) -89px -11px no-repeat}
GET /images/sg_icon_image_map.png?<32 hex chars>   200  335561 bytes  image/png

23 distinct image_map_key over all 32 Status rows, 23 resolved to a rule, 0 unresolved

Notes

Two calls, not one per status

Status.icon is an entity link, so ?fields=icon is returned under relationships as {"id": 2, "name": "Approved", "type": "Icon"} and nothing else: no display_type, no image_map_key, no url. A dotted path through it returns the Icon columns flattened into attributes under the literal keys icon.Icon.display_type and so on, with relationships empty (probe 003). One _search then answers for the whole picker.

route calls on the probed site
_search with dotted icon.Icon.* 1 14 statuses in 313ms
_search for icon, then GET /entity/icons and join on the link id 2 308ms for the 98 icons
GET /entity/statuses/<id> per status 1 per status 5 rows in 1520ms

The schema call for valid_values comes first either way, so the picker is two round trips.

Icon.url is empty unless image_data is asked for beside it

The image rendering is the only one with a payload, and asking for the payload's field is what returns it:

requested url
?fields=display_type,url ""
?fields=url ""
?fields=display_type,url,image_data 994 chars
?fields omitted 994 chars
dotted, icon.Icon.image_data in the list 994 chars
dotted, icon.Icon.image_data left out ""

An empty string reads as "this icon has no image", which is true of the other two renderings and false here. Ask for image_data even when only url is wanted.

The three renderings

display_type what the row holds what to draw
image_map image_map_key, such as icon_apr; url empty, html and image_data null crop the sprite at the offset and size the CSS rule gives
image url, a data:image/png;base64 URI with newlines in it; image_map_key and html null strip the newlines and use the URI as it stands
html html, the badge text; url empty, image_map_key and image_data null the text on rgb(<bg_color>), no image

url for the image case is exactly "data:image/png;base64," + image_data, newlines included, so either field alone is enough. On the probed site the one custom upload is 994 chars with 16 newlines, 978 once stripped.

Rediscover the sprite; never hardcode it

Nothing in /entity/icons names a stylesheet or an image. image_map_key is a CSS class, and the rule for it gives the sheet, the offset and the size. Both the stylesheet href and the sprite href end in a hash that changes per release, so a client fetches the site root, reads the .css hrefs off it, and matches .<image_map_key> in what comes back. On the probed site the stylesheet is still the /dist/production/stylesheets/login.css that probe 010 recorded, now served with ?4eed9fe, and it is one of three the root page names; the sprite href ends in 32 hex characters. Cache the concatenated CSS: it is 771416 bytes there, against 10787 and 62522 for the other two sheets.

Fall back to bg_color

A coloured badge with the label is a complete answer and needs no second request. bg_color is comma-separated RGB ("25,118,27"), not hex, so it goes into rgb(...) rather than after a #. It is also null on some rows, so keep a neutral of your own behind it. Take that path whenever the stylesheet fetch fails, the rule is missing, or the client will not draw images at all.

The picker is not a validator

hidden_values is not a subset of valid_values, and the API never enforces it: a hidden code writes at 200 and reads back (field_types/status_list). Subtracting is right for offering a choice and wrong for testing a row. On the probed site project 91 hides awd, bid and to on Shot, and 9 of its 32 Shots hold to. A cleared field reads null and fails the same membership test for a different reason. Label a stored value off display_values, which covers the whole vocabulary, and restrict only what a person may pick.

The other pieces

  • Read the schema per entity type. Codes do not transfer: on the probed site Version has 16 and Task 10, overlapping on five, and HumanUser has act and dis, which no other type offers.
  • display_values is a map from code to label and a key can be missing, so fall back to the code rather than dropping the option.
  • valid_values order is the order to show. It is not alphabetical by label, and there is no substring operator on a status_list field, so a type-ahead filters the list client-side (field_types/status_list).
  • icon_type has two values and no more. Over all 98 Icon rows on the probed site: permanent_status/image_map 94, custom_status/html 3, custom_status/image 1. Page 2 of the same listing returned 0 rows, so that census covers the whole table (probe 006).
  • Probe 010 left the question open; the answer is site configuration, and a site with more custom statuses will hold more custom_status rows, not a third icon_type.

Every entry on this site is the output of a probe in probes/. The corpus is generated by running those probes against a live Flow Production Tracking site, not written from memory.

Not affiliated with or endorsed by Autodesk. Flow Production Tracking is their product; this is an independent record of how its REST API answers.