def validate_document(doc, error_message_name, app): # The document must be either a string which points to another # file holding the document, or a dictionary. But the string # form isn't available if we're validating a new spec submitted # by the authoring tool since we don't have the app virtual # filesystem at that point. if app: if not isinstance(doc, (str, dict)): raise ValidationError(error_message_name, "Must be a file name or dictionary, not a %s." % type(doc).__name__) else: if not isinstance(doc, dict): raise ValidationError(error_message_name, "Must be a dictionary, not a %s." % type(doc).__name__) # If it's a string, slurp in the document from an external file. # The document begins with YAML dictionary terminated by a line # containing three dots. The subsequent content is stored in # the dictionary's 'template' field. The file name is stored # in the 'filename' field so that we can re-generate the original # filesystem layout in Module::serialize_to_disk. if isinstance(doc, str): error_message_name += " ({})".format(doc) # Read the external file. blob = app.read_file(doc) # Split the file on the first ocurrence of three dots. This # is YAML's standard end-of-stream marker. But PyYAML doesn't # have a way to read just up to the "...", so we handle that # ourselves. sep = "\n...\n" if sep not in blob: raise ValidationError(error_message_name, "File does not contain a line with just '...'.") data, template = blob.split(sep, 1) # Parse the YAML above the "...". data = rtyaml.load(data) # Trim the template so that it looks good if the revised # module spec is serialized to YAML. template = template.rstrip() + "\n" # Store the filename and template in it. data['filename'] = doc data['template'] = template doc = data # Check that the template is valid. try: render_content(doc, None, "PARSE_ONLY", "(document template)") except KeyError as e: raise ValidationError(error_message_name, "Missing field: %s" % str(e)) except ValueError as e: raise ValidationError(error_message_name, "Invalid template: %s" % str(e)) return doc
def validate_module(spec, is_authoring_tool=False): # Validate that the introduction and output documents are renderable. if "introduction" in spec: if not isinstance(spec["introduction"], dict): raise ValidationError( "module introduction", "Must be a dictionary, not a %s." % str(type(spec["introduction"]))) try: render_content(spec["introduction"], None, "PARSE_ONLY", "(introduction)") except ValueError as e: raise ValidationError("module introduction", "Invalid Jinja2 template: " + str(e)) if not isinstance(spec.get("output", []), list): raise ValidationError( "module output", "Must be a list, not a %s." % str(type(spec.get("output")))) for i, doc in enumerate(spec.get("output", [])): try: render_content(doc, None, "PARSE_ONLY", "(output document)") except ValueError as e: raise ValidationError("output document #%d" % (i + 1), "Invalid Jinja2 template: %s" % str(e)) # 'introduction' fields are an alias for an interstitial # question that comes before all other questions, and since # it is first it will be asked first. Except in projects, # where it's just a renderable field. if "introduction" in spec and spec.get("type") != "project": q = { "id": "_introduction", "title": "Introduction", "type": "interstitial", "prompt": spec["introduction"]["template"], } spec.setdefault("questions", []).insert(0, q) if not is_authoring_tool: # Validate the questions. # The authoring tool does not provide questions data. if not isinstance(spec.get("questions"), list): raise ValidationError("module questions", "Invalid value for 'questions'.") for i, q in enumerate(spec.get("questions", [])): spec["questions"][i] = validate_question(spec, spec["questions"][i]) return spec
def render_markdown_field(field, output_format, **kwargs): template = q.spec.get(field) if not template: return None if not isinstance(template, str): raise ValueError("%s question %s %s is not a string" % (repr(q.module), q.key, field)) return module_logic.render_content({ "template": template, "format": "markdown", }, answered, output_format, "%s question %s %s" % (repr(q.module), q.key, field), **kwargs )
def validate_question(mspec, spec): if not spec.get("id"): raise ValidationError("module questions", "A question is missing an id.") def invalid(msg): raise ValidationError("question %s" % spec['id'], msg) # clone dict before updating spec = OrderedDict(spec) # Since question IDs become Jinja2 identifiers, they must be valid # Jinaj2 identifiers. http://jinja.pocoo.org/docs/2.9/api/#notes-on-identifiers if not re.match("^[a-zA-Z_][a-zA-Z0-9_]*$", spec["id"]): invalid( "The question ID may only contain ASCII letters, numbers, and underscores, and the first character must be a letter or underscore." ) # Perform type conversions, validation, and fill in some defaults in the YAML # schema so that the values are ready to use in the database. if spec.get("type") == "multiple-choice": # validate and type-convert min and max spec["min"] = spec.get("min", 0) if not isinstance(spec["min"], int) or spec["min"] < 0: invalid("min must be a positive integer") spec["max"] = None if ("max" not in spec) else spec["max"] if spec["max"] is not None: if not isinstance(spec["max"], int) or spec["max"] < 0: invalid("max must be a positive integer") elif spec.get("type") in ("module", "module-set"): if "module-id" in spec: # Resolve the relative module ID to an absolute path relative # to the root of this app. It's optional because a protocol # can be specified instead. spec["module-id"] = resolve_relative_module_id( mspec, spec.get("module-id")) elif "protocol" in spec: pass else: invalid("Question must have a module-id or protocol field.") elif spec.get("type") == None: invalid("Question is missing a type.") # Check that required fields are present. if spec.get("prompt") is None: # Prompts are optional in project and system modules but required elsewhere. if mspec.get("type") not in ("project", "system-project"): invalid("Question prompt is missing.") # Check that the prompt, placeholder, and default are valid Jinja2 templates. for field in ("prompt", "placeholder", "default"): if field not in spec: continue if not isinstance(spec.get(field), str): invalid("Question %s must be a string, not a %s." % (field, str(type(spec.get(field))))) try: render_content({ "format": "markdown", "template": spec[field], }, None, "PARSE_ONLY", "(question %s)" % field) except ValueError as e: invalid("Question %s is an invalid Jinja2 template: %s" % (field, e)) # Validate impute conditions. imputes = spec.get("impute", []) if not isinstance(imputes, list): invalid("Impute's value must be a list.") for i, rule in enumerate(imputes): def invalid_rule(msg): raise ValidationError( mspec['id'] + " question %s, impute condition %d" % (spec['id'], i + 1), msg) # Check that the condition is a string, and that it's a valid Jinja2 expression. from jinja2.sandbox import SandboxedEnvironment env = SandboxedEnvironment() if "condition" in rule: if not isinstance(rule.get("condition"), str): invalid_rule("Impute condition must be a string, not a %s." % str(type(rule["condition"]))) try: env.compile_expression(rule["condition"]) except Exception as e: invalid_rule( "Impute condition %s is an invalid Jinja2 expression: %s." % (repr(rule["condition"]), str(e))) # Check that the value is valid. If the value-mode is raw, which # is the default, then any Python/YAML value is valid. We only # check expression values. if rule.get("value-mode") == "expression": try: env.compile_expression(rule["value"]) except Exception as e: invalid_rule( "Impute condition value %s is an invalid Jinja2 expression: %s." % (repr(rule["value"]), str(e))) if rule.get("value-mode") == "template": try: env.from_string(rule["value"]) except Exception as e: invalid_rule( "Impute condition value %s is an invalid Jinja2 template: %s." % (repr(rule["value"]), str(e))) return spec
def render_app_catalog_entry(appversion, appversions, organization): from guidedmodules.module_logic import render_content from guidedmodules.models import image_to_dataurl key = "{source}/{name}".format(source=appversion.source.slug, name=appversion.appname) catalog = appversion.catalog_metadata if not isinstance(catalog, dict): catalog = {} app_module = appversion.modules.filter(module_name="app").first() return { # app identification "appversion_id": appversion.id, "appsource_id": appversion.source.id, "key": key, # main display fields "title": catalog.get('title') or appversion.appname, "description": { # rendered as markdown "short": render_content( { "template": catalog.get("description", {}).get("short") or "", "format": "markdown", }, None, "html", "%s %s" % (key, "short description")), "long": render_content( { "template": catalog.get("description", {}).get("long") or catalog.get("description", {}).get("short") or "", "format": "markdown", }, None, "html", "%s %s" % (key, "short description")) }, # catalog page metadata "categories": catalog.get("categories", [catalog.get("category")]), "search_haystak": "".join([ # free text search uses this appversion.appname, catalog.get('title', ""), catalog.get("vendor", ""), catalog.get("description", {}).get("short", ""), catalog.get("description", {}).get("long", ""), ]), # "icon": None if "icon" not in catalog # else image_to_dataurl(appversion.get_asset(catalog["icon"]), 128), "icon": None, "protocol": app_module.spec.get("protocol", []) if app_module else [], # catalog detail page metadata "vendor": catalog.get("vendor"), "vendor_url": catalog.get("vendor_url"), "source_url": catalog.get("source_url"), "status": catalog.get("status"), "version": appversion.version_number, "recommended_for": catalog.get("recommended_for", []), # versions that can be started "versions": appversions, # organizations that can launch this app "organizations": {organization}, # placeholder for future logic "authz": "none", }
def validate_module(spec, is_authoring_tool=False): # Validate that the introduction and output documents are renderable. if "introduction" in spec: if not isinstance(spec["introduction"], dict): raise ValidationError( "module introduction", "Must be a dictionary, not a %s." % type(spec["introduction"]).__name__) try: render_content(spec["introduction"], None, "PARSE_ONLY", "(introduction)") except ValueError as e: raise ValidationError("module introduction", "Invalid Jinja2 template: " + str(e)) if not isinstance(spec.get("output", []), list): raise ValidationError( "module output", "Must be a list, not a %s." % type(spec.get("output")).__name__) for i, doc in enumerate(spec.get("output", [])): try: render_content(doc, None, "PARSE_ONLY", "(output document)") except ValueError as e: raise ValidationError("output document #%d" % (i + 1), "Invalid Jinja2 template: %s" % str(e)) # 'introduction' fields are an alias for an interstitial # question that comes before all other questions, and since # it is first it will be asked first. Except in projects, # where it's just a renderable field. if "introduction" in spec and spec.get("type") != "project": q = { "id": "_introduction", "title": "Introduction", "type": "interstitial", "prompt": spec["introduction"]["template"], } spec.setdefault("questions", []).insert(0, q) # Validate an app protocol. if "protocol" in spec: if spec.get("type") != "project": raise ValidationError( "module specification", "A protocol cannot be specified in this type of module.") if isinstance(spec["protocol"], str): # If a single protocol is given, turn it into a list of one. spec["protocol"] = [spec["protocol"]] elif isinstance(spec["protocol"], list): # If it's a list, the values must be strings. for x in spec["protocol"]: if not isinstance(x, str): raise ValidationError( "protocols", "Protocols must be strings (not %s)." % repr(x)) else: raise ValidationError( "module specification", "protocol must be a string or a list of strings (not %s)." % repr(spec["protocol"])) # Validate the questions. if not isinstance(spec.get("questions"), (type(None), list)): raise ValidationError("module questions", "Invalid data type of value for 'questions'.") for i, q in enumerate(spec.get("questions", [])): spec["questions"][i] = validate_question(spec, spec["questions"][i]) return spec