Handling Action Menu Item Calls

This is an example ActionMenu Python class to handle the GET request sent from an ActionMenuItem. It doesn’t manage dispatching custom protocols but rather takes the arguments from any GET data and parses them into the easily accessible and correctly typed instance variables for your Python scripts.

Available as a Gist at https://gist.github.com/3253287

GET vs. POST

Action Menu Items that open a url via http or https to another web server send their data via POST. If you’re using a custom protocol the data is sent via GET.

Note

Browsers limit the length of a GET request. If you exceed this limit by attempting to select a lot of rows and launch your custom protocol, you may encounter “Failed to load resource” errors in your console.

#!/usr/bin/env python
# encoding: utf-8

# ---------------------------------------------------------------------------------------------
# Description
# ---------------------------------------------------------------------------------------------
"""
The values sent by the Action Menu Item are in the form of a GET request that is similar to the
format: myCoolProtocol://doSomethingCool?user_id=24&user_login=shotgun&title=All%20Versions&...

In a more human-readable state that would translate to something like this:
{
    'project_name': 'Demo Project',
     'user_id': '24',
     'title': 'All Versions',
     'user_login': 'shotgun',
     'sort_column': 'created_at',
     'entity_type': 'Version',
     'cols': 'created_at',
     'ids': '5,2',
     'selected_ids': '2,5',
     'sort_direction': 'desc',
     'project_id': '4',
     'session_uuid': 'd8592bd6-fc41-11e1-b2c5-000c297a5f50',
     'column_display_names':
    [
        'Version Name',
         'Thumbnail',
         'Link',
         'Artist',
         'Description',
         'Status',
         'Path to frames',
         'QT',
         'Date Created'
    ]
}

This simple class parses the url into easy to access types variables from the parameters,
action, and protocol sections of the url. This example url
myCoolProtocol://doSomethingCool?user_id=123&user_login=miled&title=All%20Versions&...
would be parsed like this:

    (string) protocol: myCoolProtocol
    (string) action: doSomethingCool
    (dict)   params: user_id=123&user_login=miled&title=All%20Versions&...

The parameters variable will be returned as a dictionary of string key/value pairs. Here's
how to instantiate:

  sa = ShotgunAction(sys.argv[1]) # sys.argv[1]

  sa.params['user_login'] # returns 'miled'
  sa.params['user_id'] # returns 123
  sa.protocol # returns 'myCoolProtocol'
"""


# ---------------------------------------------------------------------------------------------
# Imports
# ---------------------------------------------------------------------------------------------
import sys, os
import six
import logging as logger

# ---------------------------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------------------------
# location to write logfile for this script
# logging is a bit of overkill for this class, but can still be useful.
logfile = os.path.dirname(sys.argv[0]) + "/shotgun_action.log"


# ----------------------------------------------
# Generic ShotgunAction Exception Class
# ----------------------------------------------
class ShotgunActionException(Exception):
    pass


# ----------------------------------------------
# ShotgunAction Class to manage ActionMenuItem call
# ----------------------------------------------
class ShotgunAction:
    def __init__(self, url):
        self.logger = self._init_log(logfile)
        self.url = url
        self.protocol, self.action, self.params = self._parse_url()

        # entity type that the page was displaying
        self.entity_type = self.params["entity_type"]

        # Project info (if the ActionMenuItem was launched from a page not belonging
        # to a Project (Global Page, My Page, etc.), this will be blank
        if "project_id" in self.params:
            self.project = {
                "id": int(self.params["project_id"]),
                "name": self.params["project_name"],
            }
        else:
            self.project = None

        # Internal column names currently displayed on the page
        self.columns = self.params["cols"]

        # Human readable names of the columns currently displayed on the page
        self.column_display_names = self.params["column_display_names"]

        # All ids of the entities returned by the query (not just those visible on the page)
        self.ids = []
        if len(self.params["ids"]) > 0:
            ids = self.params["ids"].split(",")
            self.ids = [int(id) for id in ids]

        # All ids of the entities returned by the query in filter format ready
        # to use in a find() query
        self.ids_filter = self._convert_ids_to_filter(self.ids)

        # ids of entities that were currently selected
        self.selected_ids = []
        if len(self.params["selected_ids"]) > 0:
            sids = self.params["selected_ids"].split(",")
            self.selected_ids = [int(id) for id in sids]

        # All selected ids of the entities returned by the query in filter format ready
        # to use in a find() query
        self.selected_ids_filter = self._convert_ids_to_filter(self.selected_ids)

        # sort values for the page
        # (we don't allow no sort anymore, but not sure if there's legacy here)
        if "sort_column" in self.params:
            self.sort = {
                "column": self.params["sort_column"],
                "direction": self.params["sort_direction"],
            }
        else:
            self.sort = None

        # title of the page
        self.title = self.params["title"]

        # user info who launched the ActionMenuItem
        self.user = {"id": self.params["user_id"], "login": self.params["user_login"]}

        # session_uuid
        self.session_uuid = self.params["session_uuid"]

    # ----------------------------------------------
    # Set up logging
    # ----------------------------------------------
    def _init_log(self, filename="shotgun_action.log"):
        try:
            logger.basicConfig(
                level=logger.DEBUG,
                format="%(asctime)s %(levelname)-8s %(message)s",
                datefmt="%Y-%b-%d %H:%M:%S",
                filename=filename,
                filemode="w+",
            )
        except IOError as e:
            raise ShotgunActionException("Unable to open logfile for writing: %s" % e)
        logger.info("ShotgunAction logging started.")
        return logger

        # ----------------------------------------------

    # Parse ActionMenuItem call into protocol, action and params
    # ----------------------------------------------
    def _parse_url(self):
        logger.info("Parsing full url received: %s" % self.url)

        # get the protocol used
        protocol, path = self.url.split(":", 1)
        logger.info("protocol: %s" % protocol)

        # extract the action
        action, params = path.split("?", 1)
        action = action.strip("/")
        logger.info("action: %s" % action)

        # extract the parameters
        # 'column_display_names' and 'cols' occurs once for each column displayed so we store it as a list
        params = params.split("&")
        p = {"column_display_names": [], "cols": []}
        for arg in params:
            key, value = map(six.moves.urllib.parse.unquote, arg.split("=", 1))
            if key == "column_display_names" or key == "cols":
                p[key].append(value)
            else:
                p[key] = value
        params = p
        logger.info("params: %s" % params)
        return (protocol, action, params)

    # ----------------------------------------------
    # Convert IDs to filter format to us in find() queries
    # ----------------------------------------------
    def _convert_ids_to_filter(self, ids):
        filter = []
        for id in ids:
            filter.append(["id", "is", id])
        logger.debug("parsed ids into: %s" % filter)
        return filter


# ----------------------------------------------
# Main Block
# ----------------------------------------------
if __name__ == "__main__":
    try:
        sa = ShotgunAction(sys.argv[1])
        logger.info("ShotgunAction: Firing... %s" % (sys.argv[1]))
    except IndexError as e:
        raise ShotgunActionException("Missing GET arguments")
    logger.info("ShotgunAction process finished.")