mirror of https://github.com/dirtbags/moth.git
Merge pull request #78 from int00h5525/add_objectives_support
Add objectives support
This commit is contained in:
commit
e4e106a3ec
|
@ -9,7 +9,8 @@ RUN apk --no-cache add \
|
||||||
&& \
|
&& \
|
||||||
pip3 install \
|
pip3 install \
|
||||||
scapy==2.4.2 \
|
scapy==2.4.2 \
|
||||||
pillow==5.4.1
|
pillow==5.4.1 \
|
||||||
|
PyYAML==5.1.1
|
||||||
|
|
||||||
|
|
||||||
COPY devel /app/
|
COPY devel /app/
|
||||||
|
|
105
devel/moth.py
105
devel/moth.py
|
@ -13,6 +13,7 @@ import random
|
||||||
import string
|
import string
|
||||||
import tempfile
|
import tempfile
|
||||||
import shlex
|
import shlex
|
||||||
|
import yaml
|
||||||
|
|
||||||
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||||
|
|
||||||
|
@ -59,6 +60,34 @@ class PuzzleFile:
|
||||||
self.name = name
|
self.name = name
|
||||||
self.visible = visible
|
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:
|
class Puzzle:
|
||||||
def __init__(self, category_seed, points):
|
def __init__(self, category_seed, points):
|
||||||
|
@ -80,6 +109,13 @@ class Puzzle:
|
||||||
self.hint = None
|
self.hint = None
|
||||||
self.files = {}
|
self.files = {}
|
||||||
self.body = io.StringIO()
|
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.logs = []
|
||||||
self.randseed = category_seed * self.points
|
self.randseed = category_seed * self.points
|
||||||
self.rand = random.Random(self.randseed)
|
self.rand = random.Random(self.randseed)
|
||||||
|
@ -91,21 +127,64 @@ class Puzzle:
|
||||||
|
|
||||||
def read_stream(self, stream):
|
def read_stream(self, stream):
|
||||||
header = True
|
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:
|
||||||
|
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:
|
||||||
|
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:
|
for line in stream:
|
||||||
if header:
|
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
header = False
|
break
|
||||||
continue
|
|
||||||
key, val = line.split(':', 1)
|
key, val = line.split(':', 1)
|
||||||
key = key.lower()
|
key = key.lower()
|
||||||
val = val.strip()
|
val = val.strip()
|
||||||
|
self.handle_header_key(key, val)
|
||||||
|
|
||||||
|
def handle_header_key(self, key, val):
|
||||||
if key == 'author':
|
if key == 'author':
|
||||||
self.authors.append(val)
|
self.authors.append(val)
|
||||||
elif key == 'summary':
|
elif key == 'summary':
|
||||||
self.summary = val
|
self.summary = val
|
||||||
elif key == 'answer':
|
elif key == 'answer':
|
||||||
|
if not isinstance(val, str):
|
||||||
|
raise ValueError("Answers must be strings, got %s, instead" % (type(val),))
|
||||||
self.answers.append(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':
|
elif key == 'pattern':
|
||||||
self.pattern = val
|
self.pattern = val
|
||||||
elif key == 'hint':
|
elif key == 'hint':
|
||||||
|
@ -128,10 +207,22 @@ class Puzzle:
|
||||||
# Make sure this shows up in the header block of the HTML output.
|
# Make sure this shows up in the header block of the HTML output.
|
||||||
self.files[val] = PuzzleFile(stream, val, visible=False)
|
self.files[val] = PuzzleFile(stream, val, visible=False)
|
||||||
self.scripts.append(val)
|
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:
|
else:
|
||||||
raise ValueError("Unrecognized header field: {}".format(key))
|
raise ValueError("Unrecognized header field: {}".format(key))
|
||||||
else:
|
|
||||||
self.body.write(line)
|
|
||||||
|
|
||||||
def read_directory(self, path):
|
def read_directory(self, path):
|
||||||
try:
|
try:
|
||||||
|
@ -278,6 +369,10 @@ class Puzzle:
|
||||||
'scripts': self.scripts,
|
'scripts': self.scripts,
|
||||||
'pattern': self.pattern,
|
'pattern': self.pattern,
|
||||||
'body': self.html_body(),
|
'body': self.html_body(),
|
||||||
|
'objective': self.objective,
|
||||||
|
'success': self.success,
|
||||||
|
'solution': self.solution,
|
||||||
|
'ksas': self.ksas,
|
||||||
}
|
}
|
||||||
|
|
||||||
def hashes(self):
|
def hashes(self):
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
|
import re
|
||||||
|
|
||||||
import moth
|
import moth
|
||||||
|
|
||||||
|
@ -70,7 +71,9 @@ class MothValidator:
|
||||||
def check_fields(self, puzzle):
|
def check_fields(self, puzzle):
|
||||||
"""Check if the puzzle has the requested fields"""
|
"""Check if the puzzle has the requested fields"""
|
||||||
for field in self.required_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,))
|
raise MothValidationError("Missing field %s" % (field,))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -132,14 +135,34 @@ class MothValidator:
|
||||||
|
|
||||||
puzzle.body.seek(old_pos)
|
puzzle.body.seek(old_pos)
|
||||||
|
|
||||||
# Leaving this as a placeholder until KSAs are formally supported
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_ksa_format(puzzle):
|
def check_ksa_format(puzzle):
|
||||||
"""Check if KSAs are properly formatted"""
|
"""Check if KSAs are properly formatted"""
|
||||||
|
|
||||||
|
ksa_re = re.compile("^[KSA]\d{4}$")
|
||||||
|
|
||||||
if hasattr(puzzle, "ksa"):
|
if hasattr(puzzle, "ksa"):
|
||||||
for ksa in puzzle.ksa:
|
for ksa in puzzle.ksa:
|
||||||
if not ksa.startswith("K"):
|
if ksa_re.match(ksa) is None:
|
||||||
raise MothValidationError("Unrecognized KSA format")
|
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):
|
def output_json(data):
|
||||||
|
|
Loading…
Reference in New Issue