diff --git a/Dockerfile.moth-devel b/Dockerfile.moth-devel index 6e8466a..a667a8e 100644 --- a/Dockerfile.moth-devel +++ b/Dockerfile.moth-devel @@ -9,7 +9,8 @@ RUN apk --no-cache add \ && \ pip3 install \ scapy==2.4.2 \ - pillow==5.4.1 + pillow==5.4.1 \ + PyYAML==5.1.1 COPY devel /app/ diff --git a/devel/moth.py b/devel/moth.py index 612d371..1875bd7 100644 --- a/devel/moth.py +++ b/devel/moth.py @@ -13,6 +13,7 @@ import random import string import tempfile import shlex +import yaml messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' @@ -59,6 +60,34 @@ class PuzzleFile: self.name = name self.visible = visible +class PuzzleSuccess(dict): + """Puzzle success objectives + + :param acceptable: Learning outcome from acceptable knowledge of the subject matter + :param mastery: Learning outcome from mastery of the subject matter + """ + + valid_fields = ["acceptable", "mastery"] + + def __init__(self, **kwargs): + super(PuzzleSuccess, self).__init__() + for key in self.valid_fields: + self[key] = None + for key, value in kwargs.items(): + if key in self.valid_fields: + self[key] = value + + def __getattr__(self, attr): + if attr in self.valid_fields: + return self[attr] + raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr)) + + def __setattr__(self, attr, value): + if attr in self.valid_fields: + self[attr] = value + else: + raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr)) + class Puzzle: def __init__(self, category_seed, points): @@ -80,6 +109,13 @@ class Puzzle: self.hint = None self.files = {} self.body = io.StringIO() + + # NIST NICE objective content + self.objective = None # Text describing the expected learning outcome from solving this puzzle, *why* are you solving this puzzle + self.success = PuzzleSuccess() # Text describing criteria for different levels of success, e.g. {"Acceptable": "Did OK", "Mastery": "Did even better"} + self.solution = None # Text describing how to solve the puzzle + self.ksas = [] # A list of references to related NICE KSAs (e.g. K0058, . . .) + self.logs = [] self.randseed = category_seed * self.points self.rand = random.Random(self.randseed) @@ -91,47 +127,102 @@ class Puzzle: def read_stream(self, stream): header = True + line = "" + if stream.read(3) == "---": + header = "yaml" + else: + header = "moth" + + stream.seek(0) + + if header == "yaml": + self.read_yaml_header(stream) + elif header == "moth": + self.read_moth_header(stream) + for line in stream: - if header: - line = line.strip() - if not line: - header = False - continue - key, val = line.split(':', 1) - key = key.lower() - val = val.strip() - if key == 'author': - self.authors.append(val) - elif key == 'summary': - self.summary = val - elif key == 'answer': - self.answers.append(val) - elif key == 'pattern': - self.pattern = val - elif key == 'hint': - self.hint = val - elif key == 'name': - pass - elif key == 'file': - parts = shlex.split(val) - name = parts[0] - hidden = False - stream = open(name, 'rb') - try: - name = parts[1] - hidden = (parts[2].lower() == "hidden") - except IndexError: - pass - self.files[name] = PuzzleFile(stream, name, not hidden) - elif key == 'script': - stream = open(val, 'rb') - # Make sure this shows up in the header block of the HTML output. - self.files[val] = PuzzleFile(stream, val, visible=False) - self.scripts.append(val) - else: - raise ValueError("Unrecognized header field: {}".format(key)) + self.body.write(line) + + def read_yaml_header(self, stream): + contents = "" + header = False + for line in stream: + if line.strip() == "---" and header: # Handle last line + break + elif line.strip() == "---": # Handle first line + header = True + continue else: - self.body.write(line) + contents += line + + config = yaml.safe_load(contents) + for key, value in config.items(): + key = key.lower() + self.handle_header_key(key, value) + + def read_moth_header(self, stream): + for line in stream: + line = line.strip() + if not line: + break + + key, val = line.split(':', 1) + key = key.lower() + val = val.strip() + self.handle_header_key(key, val) + + def handle_header_key(self, key, val): + if key == 'author': + self.authors.append(val) + elif key == 'summary': + self.summary = val + elif key == 'answer': + if not isinstance(val, str): + raise ValueError("Answers must be strings, got %s, instead" % (type(val),)) + self.answers.append(val) + elif key == "answers": + for answer in val: + if not isinstance(answer, str): + raise ValueError("Answers must be strings, got %s, instead" % (type(answer),)) + self.answers.append(answer) + elif key == 'pattern': + self.pattern = val + elif key == 'hint': + self.hint = val + elif key == 'name': + pass + elif key == 'file': + parts = shlex.split(val) + name = parts[0] + hidden = False + stream = open(name, 'rb') + try: + name = parts[1] + hidden = (parts[2].lower() == "hidden") + except IndexError: + pass + self.files[name] = PuzzleFile(stream, name, not hidden) + elif key == 'script': + stream = open(val, 'rb') + # Make sure this shows up in the header block of the HTML output. + self.files[val] = PuzzleFile(stream, val, visible=False) + self.scripts.append(val) + elif key == "objective": + self.objective = val + elif key == "success": + # Force success dictionary keys to be lower-case + self.success = dict((x.lower(), y) for x,y in val.items()) + elif key == "success.acceptable": + self.success.acceptable = val + elif key == "success.mastery": + self.success.mastery = val + elif key == "solution": + self.solution = val + elif key == "ksa": + self.ksas.append(val) + else: + raise ValueError("Unrecognized header field: {}".format(key)) + def read_directory(self, path): try: @@ -278,6 +369,10 @@ class Puzzle: 'scripts': self.scripts, 'pattern': self.pattern, 'body': self.html_body(), + 'objective': self.objective, + 'success': self.success, + 'solution': self.solution, + 'ksas': self.ksas, } def hashes(self): diff --git a/devel/validate.py b/devel/validate.py index 150f0d3..d73dd89 100644 --- a/devel/validate.py +++ b/devel/validate.py @@ -5,6 +5,7 @@ import logging import os import os.path +import re import moth @@ -70,7 +71,9 @@ class MothValidator: def check_fields(self, puzzle): """Check if the puzzle has the requested fields""" for field in self.required_fields: - if not hasattr(puzzle, field): + if not hasattr(puzzle, field) or \ + getattr(puzzle,field) is None or \ + getattr(puzzle,field) == "": raise MothValidationError("Missing field %s" % (field,)) @staticmethod @@ -132,14 +135,34 @@ class MothValidator: puzzle.body.seek(old_pos) - # Leaving this as a placeholder until KSAs are formally supported @staticmethod def check_ksa_format(puzzle): """Check if KSAs are properly formatted""" + + ksa_re = re.compile("^[KSA]\d{4}$") + if hasattr(puzzle, "ksa"): for ksa in puzzle.ksa: - if not ksa.startswith("K"): - raise MothValidationError("Unrecognized KSA format") + if ksa_re.match(ksa) is None: + raise MothValidationError("Unrecognized KSA format (%s)" % (ksa,)) + + @staticmethod + def check_success(puzzle): + """Check if success criteria are defined""" + + if not hasattr(puzzle, "success"): + raise MothValidationError("Success not defined") + + criteria = ["acceptable", "mastery"] + missing_criteria = [] + for criterion in criteria: + if criterion not in puzzle.success.keys() or \ + puzzle.success[criterion] is None or \ + len(puzzle.success[criterion]) == 0: + missing_criteria.append(criterion) + + if len(missing_criteria) > 0: + raise MothValidationError("Missing success criteria (%s)" % (", ".join(missing_criteria))) def output_json(data):