From be881c0ad610bd8070c329434d1e64c01573afa9 Mon Sep 17 00:00:00 2001 From: Johan Berggren Date: Fri, 23 Aug 2024 14:34:25 +0200 Subject: [PATCH] Support for DFIQ v1.1 (#3163) * 1.1 compatibilty * UUID field for DFIQ models * Deep link to question * support creation of scenarios & questions based on uuid --------- Co-authored-by: Janosch <99879757+jkppr@users.noreply.github.com> --- test_data/dfiq/approaches/Q1001.01.yaml | 18 ---- test_data/dfiq/facets/F1001.yaml | 3 +- test_data/dfiq/questions/Q1001.yaml | 21 ++++- test_data/dfiq/scenarios/S1001.yaml | 3 +- timesketch/api/v1/resources/__init__.py | 3 + timesketch/api/v1/resources/scenarios.py | 90 +++++++++++------- .../components/Scenarios/QuestionApproach.vue | 24 ++--- .../src/components/Scenarios/QuestionCard.vue | 73 ++++++++------ timesketch/lib/dfiq.py | 94 ++++++++----------- timesketch/lib/dfiq_test.py | 25 +---- ...60d97a2c8_add_uuid_field_to_dfiq_models.py | 45 +++++++++ timesketch/models/sketch.py | 3 + 12 files changed, 228 insertions(+), 174 deletions(-) delete mode 100644 test_data/dfiq/approaches/Q1001.01.yaml create mode 100644 timesketch/migrations/versions/c5560d97a2c8_add_uuid_field_to_dfiq_models.py diff --git a/test_data/dfiq/approaches/Q1001.01.yaml b/test_data/dfiq/approaches/Q1001.01.yaml deleted file mode 100644 index 43516f83e3..0000000000 --- a/test_data/dfiq/approaches/Q1001.01.yaml +++ /dev/null @@ -1,18 +0,0 @@ -display_name: Test Approach -description: - summary: Test Approach - details: Test Approach - references: - - https://www.example.com -type: approach -id: Q1001.01 -tags: - - test -view: - processors: - - name: plaso - analysis: - timesketch: - - description: Test SearchTemplate - type: searchtemplate - value: 770754f3-2419-4a6c-ba45-ec9dbd3240ce diff --git a/test_data/dfiq/facets/F1001.yaml b/test_data/dfiq/facets/F1001.yaml index 82ea66832f..54cb941cbc 100644 --- a/test_data/dfiq/facets/F1001.yaml +++ b/test_data/dfiq/facets/F1001.yaml @@ -1,7 +1,8 @@ -display_name: Test Facet +name: Test Facet description: Test Facet type: facet id: F1001 +uuid: 79c6d69d-4de0-4fc0-af5f-b02248430104 tags: - test parent_ids: diff --git a/test_data/dfiq/questions/Q1001.yaml b/test_data/dfiq/questions/Q1001.yaml index c1bd20568e..dc667b41ef 100644 --- a/test_data/dfiq/questions/Q1001.yaml +++ b/test_data/dfiq/questions/Q1001.yaml @@ -1,7 +1,26 @@ -display_name: Test Question +name: Test Question description: Test Question type: question id: Q1001 +uuid: bb094c9a-afb7-4daf-a853-7859d2f99d0c +approaches: +- name: Test approach + description: Test approach description + tags: + - Test Tag + references: + - '[Test Tag](http://go/yeti-dev)' + notes: + covered: + - Test covered + not_covered: + - Test not covered + steps: + - name: Test step + description: Test step description + stage: collection + type: ForensicArtifact + value: TestArtifact tags: parent_ids: - F1001 diff --git a/test_data/dfiq/scenarios/S1001.yaml b/test_data/dfiq/scenarios/S1001.yaml index 8369390f2a..da24a8cbdf 100644 --- a/test_data/dfiq/scenarios/S1001.yaml +++ b/test_data/dfiq/scenarios/S1001.yaml @@ -1,6 +1,7 @@ -display_name: Test Scenario +name: Test Scenario description: Test Scenario type: scenario id: S1001 +uuid: 6e5d9e63-b477-4392-8742-c85ac9b653e2 tags: - test diff --git a/timesketch/api/v1/resources/__init__.py b/timesketch/api/v1/resources/__init__.py index c5f068950e..9092b3b4f8 100644 --- a/timesketch/api/v1/resources/__init__.py +++ b/timesketch/api/v1/resources/__init__.py @@ -285,6 +285,7 @@ class ResourceMixin(object): "display_name": fields.String, "description": fields.String, "dfiq_identifier": fields.String, + "uuid": fields.String, "spec_json": fields.String, "user": fields.Nested(user_fields), "approaches": fields.List(fields.Nested(approach_fields)), @@ -299,6 +300,7 @@ class ResourceMixin(object): "display_name": fields.String, "description": fields.String, "dfiq_identifier": fields.String, + "uuid": fields.String, "spec_json": fields.String, "user": fields.Nested(user_fields), "questions": fields.List(fields.Nested(question_fields)), @@ -317,6 +319,7 @@ class ResourceMixin(object): "display_name": fields.String, "description": fields.String, "dfiq_identifier": fields.String, + "uuid": fields.String, "spec_json": fields.String, "user": fields.Nested(user_fields), "facets": fields.List(fields.Nested(facet_fields)), diff --git a/timesketch/api/v1/resources/scenarios.py b/timesketch/api/v1/resources/scenarios.py index a5e6b7584f..1091e2d306 100644 --- a/timesketch/api/v1/resources/scenarios.py +++ b/timesketch/api/v1/resources/scenarios.py @@ -14,6 +14,7 @@ """API for asking Timesketch scenarios for version 1 of the Timesketch API.""" import logging +import json from flask import jsonify from flask import request @@ -135,19 +136,38 @@ def post(self, sketch_id): dfiq_id = form.get("dfiq_id") display_name = form.get("display_name") + uuid = form.get("uuid") + + scenario = None + + if uuid: + scenario = next( + (s for s in dfiq.scenarios if s.uuid == uuid), + None, + ) + elif dfiq_id: + scenario = next( + (s for s in dfiq.scenarios if s.id == dfiq_id), + None, + ) + elif display_name: + scenario = next( + (s for s in dfiq.scenarios if s.name == display_name), + None, + ) - scenario = next( - (scenario for scenario in dfiq.scenarios if scenario.id == dfiq_id), - None, - ) if not scenario: - abort(HTTP_STATUS_CODE_NOT_FOUND, f"No such scenario template: {dfiq_id}") + abort( + HTTP_STATUS_CODE_NOT_FOUND, + f"No scenario found matching the provided data: {form}", + ) if not display_name: display_name = scenario.name scenario_sql = Scenario( dfiq_identifier=scenario.id, + uuid=scenario.uuid, name=scenario.name, display_name=display_name, description=scenario.description, @@ -163,6 +183,7 @@ def post(self, sketch_id): ) facet_sql = Facet( dfiq_identifier=facet.id, + uuid=facet.uuid, name=facet.name, display_name=facet.name, description=facet.description, @@ -183,30 +204,27 @@ def post(self, sketch_id): ) question_sql = InvestigativeQuestion( dfiq_identifier=question.id, + uuid=question.uuid, name=question.name, display_name=question.name, description=question.description, spec_json=question.to_json(), sketch=sketch, - scenario=scenario_sql, + # scenario=scenario_sql, user=current_user, ) - facet_sql.questions.append(question_sql) - - for approach_id in question.approaches: - approach = next( - ( - approach - for approach in dfiq.approaches - if approach.id == approach_id - ), - None, - ) + # facet_sql.questions.append(question_sql) + + # TODO: This is a tmp hack to make all questions oprhaned! + # We need to fix this by loading connected questions as well in + # the frontend! + db_session.add(question_sql) + + for approach in question.approaches: approach_sql = InvestigativeQuestionApproach( - dfiq_identifier=approach.id, name=approach.name, display_name=approach.name, - description=approach.description.get("details", ""), + description=approach.description, spec_json=approach.to_json(), user=current_user, ) @@ -466,8 +484,8 @@ def get(self): if not dfiq: return jsonify({"objects": []}) - scenarios = [scenario.__dict__ for scenario in dfiq.questions] - return jsonify({"objects": scenarios}) + questions = [json.loads(question.to_json()) for question in dfiq.questions] + return jsonify({"objects": questions}) class QuestionListResource(resources.ResourceMixin, Resource): @@ -493,23 +511,32 @@ def post(self, sketch_id): scenario_id = form.get("scenario_id") facet_id = form.get("facet_id") template_id = form.get("template_id") + uuid = form.get("uuid") scenario = Scenario.get_by_id(scenario_id) if scenario_id else None facet = Facet.get_by_id(facet_id) if facet_id else None - if template_id: + if template_id or uuid: dfiq = load_dfiq_from_config() if not dfiq: abort( HTTP_STATUS_CODE_NOT_FOUND, "DFIQ is not configured on this server" ) - dfiq_question = [ - question for question in dfiq.questions if question.id == template_id - ][0] + if uuid: + dfiq_question = [ + question for question in dfiq.questions if question.uuid == uuid + ][0] + else: + dfiq_question = [ + question + for question in dfiq.questions + if question.id == template_id + ][0] if dfiq_question: new_question = InvestigativeQuestion( dfiq_identifier=dfiq_question.id, + uuid=dfiq_question.uuid, name=dfiq_question.name, display_name=dfiq_question.name, description=dfiq_question.description, @@ -517,20 +544,11 @@ def post(self, sketch_id): sketch=sketch, user=current_user, ) - for approach_id in dfiq_question.approaches: - approach = next( - ( - approach - for approach in dfiq.approaches - if approach.id == approach_id - ), - None, - ) + for approach in dfiq_question.approaches: approach_sql = InvestigativeQuestionApproach( - dfiq_identifier=approach.id, name=approach.name, display_name=approach.name, - description=approach.description.get("details", ""), + description=approach.description, spec_json=approach.to_json(), user=current_user, ) diff --git a/timesketch/frontend-ng/src/components/Scenarios/QuestionApproach.vue b/timesketch/frontend-ng/src/components/Scenarios/QuestionApproach.vue index f6caeed8ca..f3e73ef36d 100644 --- a/timesketch/frontend-ng/src/components/Scenarios/QuestionApproach.vue +++ b/timesketch/frontend-ng/src/components/Scenarios/QuestionApproach.vue @@ -18,7 +18,7 @@ limitations under the License.
@@ -41,11 +41,11 @@ limitations under the License.
-
+
mdi-link-variant References
    -
  • +
@@ -55,7 +55,7 @@ limitations under the License. mdi-check Covered
    -
  • {{ note }}
  • +
  • {{ note }}
@@ -63,7 +63,7 @@ limitations under the License. mdi-close Not covered
    -
  • {{ note }}
  • +
  • {{ note }}
@@ -90,17 +90,13 @@ export default { }, opensearchQueries() { let opensearchQueries = [] - this.approach._view.processors.forEach((processor) => { - processor.analysis.forEach((analysis) => { - if (analysis.name === 'OpenSearch') { - analysis.steps.forEach((step) => { - if (step.type === 'opensearch-query') { - opensearchQueries.push(step) - } - }) + if (this.approach.steps) { + this.approach.steps.forEach((step) => { + if (step.type === 'opensearch-query') { + opensearchQueries.push(step) } }) - }) + } return opensearchQueries }, }, diff --git a/timesketch/frontend-ng/src/components/Scenarios/QuestionCard.vue b/timesketch/frontend-ng/src/components/Scenarios/QuestionCard.vue index a5acb66199..f64f43f8b8 100644 --- a/timesketch/frontend-ng/src/components/Scenarios/QuestionCard.vue +++ b/timesketch/frontend-ng/src/components/Scenarios/QuestionCard.vue @@ -376,17 +376,17 @@ export default { } let queries = [] let approaches = this.activeQuestion.approaches.map((approach) => JSON.parse(approach.spec_json)) - approaches.forEach((approach) => { - approach._view.processors.forEach((processor) => { - processor.analysis.forEach((analysis) => { - if (analysis.name === 'OpenSearch') { - analysis.steps.forEach((step) => { + if (approaches) { + approaches.forEach((approach) => { + if (approach.steps) { + approach.steps.forEach((step) => { + if (step.type === 'opensearch-query') { queries.push(step) - }) - } - }) + } + }) + } }) - }) + } return queries }, currentUserConclusion() { @@ -411,7 +411,7 @@ export default { }, getSketchQuestions() { this.isLoading = true - ApiClient.getOrphanQuestions(this.sketch.id) + return ApiClient.getOrphanQuestions(this.sketch.id) .then((response) => { this.sketchQuestions = response.data.objects[0] this.isLoading = false @@ -536,27 +536,40 @@ export default { }, mounted() { EventBus.$on('createBranch', this.getSearchHistory) + let questionUUID = this.$route.query.question_uuid this.getQuestionTemplates() - this.getSketchQuestions() - // Restore active question from local storage - let storageKey = 'sketchContext' + this.sketch.id.toString() - let storedContext = localStorage.getItem(storageKey) - let context = {} - if (storedContext) { - context = JSON.parse(storedContext) - } - if (Object.keys(context).length) { - this.isLoading = true - ApiClient.getQuestion(this.sketch.id, context.questionId) - .then((response) => { - this.setActiveQuestion(response.data.objects[0]) - this.isLoading = false - }) - .catch((e) => { - console.error(e) - }) - } else { - this.showEmptySelect = true + this.getSketchQuestions().then(() => { + if (questionUUID) { + const question = this.sketchQuestions.find((question) => question.uuid === questionUUID) + if (!question) { + this.errorSnackBar('No question found with that UUID') + this.showEmptySelect = true + return + } + this.setActiveQuestion(question) + } + }) + if (!questionUUID) { + // Restore active question from local storage + let storageKey = 'sketchContext' + this.sketch.id.toString() + let storedContext = localStorage.getItem(storageKey) + let context = {} + if (storedContext) { + context = JSON.parse(storedContext) + } + if (Object.keys(context).length) { + this.isLoading = true + ApiClient.getQuestion(this.sketch.id, context.questionId) + .then((response) => { + this.setActiveQuestion(response.data.objects[0]) + this.isLoading = false + }) + .catch((e) => { + console.error(e) + }) + } else { + this.showEmptySelect = true + } } }, } diff --git a/timesketch/lib/dfiq.py b/timesketch/lib/dfiq.py index 3c305a969a..b619f96533 100644 --- a/timesketch/lib/dfiq.py +++ b/timesketch/lib/dfiq.py @@ -32,8 +32,11 @@ class Component(object): child_ids: The child IDs of the component. """ - def __init__(self, dfiq_id, name, description=None, tags=None, parent_ids=None): + def __init__( + self, dfiq_id, uuid, name, description=None, tags=None, parent_ids=None + ): self.id = dfiq_id + self.uuid = uuid self.name = name self.description = description self.tags = tags @@ -58,18 +61,21 @@ def to_json(self): ) -class Approach(Component): +class Approach: """Class that represents an approach. Attributes: - templates: The templates of the approach. + search_templates: The search templates of the approach. """ - def __init__(self, dfiq_id, name, description, tags, view): + def __init__(self, approach): """Initializes the approach.""" - self._parent_ids = [dfiq_id.split(".")[0]] - self._view = view - super().__init__(dfiq_id, name, description, tags, self._parent_ids) + self.name = approach["name"] + self.description = approach.get("description") + self.notes = approach.get("notes") + self.references = approach.get("references") + self.steps = approach.get("steps") + self.tags = approach.get("tags") def _get_timesketch_analyses(self): """Returns the Timesketch analysis provider of the approach. @@ -81,13 +87,17 @@ def _get_timesketch_analyses(self): Returns: A list of Timesketch analysis approaches. """ - timesketch_analyses = [] - for processor in self._view.get("processors", []): - if "timesketch" in processor.get("analysis", {}): - provider = processor["analysis"]["timesketch"] - for analysis in provider: - timesketch_analyses.append(analysis) - return timesketch_analyses + analysis_types = ["timesketch-searchtemplate", "opensearch-query"] + return [ + step + for step in self.steps + if step["stage"] == "analysis" and step["type"] in analysis_types + ] + + def to_json(self): + return json.dumps( + self, default=lambda o: o.__dict__, sort_keys=False, allow_nan=False + ) @property def search_templates(self): @@ -99,33 +109,27 @@ def search_templates(self): return [ analysis for analysis in self._get_timesketch_analyses() - if analysis["type"] == "searchtemplate" + if analysis["type"] == "timesketch-searchtemplate" ] class Question(Component): """Class that represents a question.""" - def __init__(self, dfiq_id, name, description, tags, parent_ids): + def __init__(self, dfiq_id, uuid, name, description, tags, parent_ids, approaches): """Initializes the question.""" - super().__init__(dfiq_id, name, description, tags, parent_ids) - - @property - def approaches(self): - """Returns the approaches of the question. - - Returns: - A list of IDs of approaches linked to this question. - """ - return self.child_ids + self.approaches = [] + if approaches: + self.approaches = [Approach(approach) for approach in approaches] + super().__init__(dfiq_id, uuid, name, description, tags, parent_ids) class Facet(Component): """Class that represents a facet.""" - def __init__(self, dfiq_id, name, description, tags, parent_ids): + def __init__(self, dfiq_id, uuid, name, description, tags, parent_ids): """Initializes the facet.""" - super().__init__(dfiq_id, name, description, tags, parent_ids) + super().__init__(dfiq_id, uuid, name, description, tags, parent_ids) @property def questions(self): @@ -140,9 +144,9 @@ def questions(self): class Scenario(Component): """Class that represents a scenario.""" - def __init__(self, dfiq_id, name, description, tags): + def __init__(self, dfiq_id, uuid, name, description, tags): """Initializes the scenario.""" - super().__init__(dfiq_id, name, description, tags) + super().__init__(dfiq_id, uuid, name, description, tags) @property def facets(self): @@ -216,18 +220,6 @@ def questions(self): key=lambda x: x.id, ) - @property - def approaches(self): - """Returns the approaches of DFIQ. - - Returns: - A list of Approach objects. - """ - return sorted( - [c for c in self.components.values() if isinstance(c, Approach)], - key=lambda x: x.id, - ) - @staticmethod def _convert_yaml_object_to_dfiq_component(yaml_object): """Converts a YAML object to a DFIQ component. @@ -238,14 +230,16 @@ def _convert_yaml_object_to_dfiq_component(yaml_object): if yaml_object["type"] == "scenario": return Scenario( yaml_object["id"], - yaml_object["display_name"], + yaml_object["uuid"], + yaml_object["name"], yaml_object.get("description"), yaml_object.get("tags"), ) if yaml_object["type"] == "facet": return Facet( yaml_object["id"], - yaml_object["display_name"], + yaml_object["uuid"], + yaml_object["name"], yaml_object.get("description"), yaml_object.get("tags"), yaml_object.get("parent_ids"), @@ -253,18 +247,12 @@ def _convert_yaml_object_to_dfiq_component(yaml_object): if yaml_object["type"] == "question": return Question( yaml_object["id"], - yaml_object["display_name"], + yaml_object["uuid"], + yaml_object["name"], yaml_object.get("description"), yaml_object.get("tags"), yaml_object.get("parent_ids"), - ) - if yaml_object["type"] == "approach": - return Approach( - yaml_object["id"], - yaml_object["display_name"], - yaml_object.get("description"), - yaml_object.get("tags"), - yaml_object.get("view"), + yaml_object.get("approaches"), ) return None diff --git a/timesketch/lib/dfiq_test.py b/timesketch/lib/dfiq_test.py index 445c979d80..244500d52e 100644 --- a/timesketch/lib/dfiq_test.py +++ b/timesketch/lib/dfiq_test.py @@ -30,39 +30,24 @@ def __init__(self, *args, **kwargs): def test_dfiq_components(self): """Test that the DFIQ components are loaded correctly.""" self.assertIsInstance(self.dfiq.components, dict) - self.assertEqual(len(self.dfiq.components), 4) + self.assertEqual(len(self.dfiq.components), 3) self.assertIsInstance(self.dfiq.components.get("S1001"), dfiq.Scenario) self.assertIsInstance(self.dfiq.components.get("F1001"), dfiq.Facet) self.assertIsInstance(self.dfiq.components.get("Q1001"), dfiq.Question) - self.assertIsInstance(self.dfiq.components.get("Q1001.01"), dfiq.Approach) self.assertEqual(len(self.dfiq.components.get("S1001").facets), 1) self.assertEqual(len(self.dfiq.components.get("F1001").questions), 1) - self.assertEqual(len(self.dfiq.components.get("Q1001").approaches), 1) - - def test_dfiq_approach(self): - """Test that the DFIQ approach is loaded correctly.""" - approach = self.dfiq.components.get("Q1001.01") - expected_content = { - "description": "Test SearchTemplate", - "type": "searchtemplate", - "value": "770754f3-2419-4a6c-ba45-ec9dbd3240ce", - } - self.assertIsInstance(approach.search_templates, list) - self.assertEqual(len(approach.search_templates), 1) - self.assertIsInstance(approach.search_templates[0], dict) - self.assertEqual(approach.search_templates[0], expected_content) def test_dfiq_graph(self): """Test that the DFIQ graph is loaded correctly.""" - self.assertEqual(len(self.dfiq.graph.nodes), 4) - self.assertEqual(len(self.dfiq.graph.edges), 3) + self.assertEqual(len(self.dfiq.graph.nodes), 3) + self.assertEqual(len(self.dfiq.graph.edges), 2) for node in self.dfiq.graph.nodes: self.assertIsInstance(node, str) - expected_nodes = ["S1001", "F1001", "Q1001", "Q1001.01"] + expected_nodes = ["S1001", "F1001", "Q1001"] for idx, component_name in enumerate(expected_nodes): self.assertEqual(list(self.dfiq.graph.nodes)[idx], component_name) for edge in self.dfiq.graph.edges: self.assertIsInstance(edge, tuple) - expected_edges = [("S1001", "F1001"), ("F1001", "Q1001"), ("Q1001", "Q1001.01")] + expected_edges = [("S1001", "F1001"), ("F1001", "Q1001")] for idx, edge in enumerate(expected_edges): self.assertEqual(list(self.dfiq.graph.edges)[idx], edge) diff --git a/timesketch/migrations/versions/c5560d97a2c8_add_uuid_field_to_dfiq_models.py b/timesketch/migrations/versions/c5560d97a2c8_add_uuid_field_to_dfiq_models.py new file mode 100644 index 0000000000..d3cfee8739 --- /dev/null +++ b/timesketch/migrations/versions/c5560d97a2c8_add_uuid_field_to_dfiq_models.py @@ -0,0 +1,45 @@ +"""Add UUID field to DFIQ models + +Revision ID: c5560d97a2c8 +Revises: 011cabf3aef0 +Create Date: 2024-08-22 09:24:34.745006 + +""" + +# This code is auto generated. Ignore linter errors. +# pylint: skip-file + +# revision identifiers, used by Alembic. +revision = "c5560d97a2c8" +down_revision = "011cabf3aef0" + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("facet", schema=None) as batch_op: + batch_op.add_column(sa.Column("uuid", sa.UnicodeText(), nullable=True)) + + with op.batch_alter_table("investigativequestion", schema=None) as batch_op: + batch_op.add_column(sa.Column("uuid", sa.UnicodeText(), nullable=True)) + + with op.batch_alter_table("scenario", schema=None) as batch_op: + batch_op.add_column(sa.Column("uuid", sa.UnicodeText(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("scenario", schema=None) as batch_op: + batch_op.drop_column("uuid") + + with op.batch_alter_table("investigativequestion", schema=None) as batch_op: + batch_op.drop_column("uuid") + + with op.batch_alter_table("facet", schema=None) as batch_op: + batch_op.drop_column("uuid") + + # ### end Alembic commands ### diff --git a/timesketch/models/sketch.py b/timesketch/models/sketch.py index 872f84de24..4e1c76d5f7 100644 --- a/timesketch/models/sketch.py +++ b/timesketch/models/sketch.py @@ -537,6 +537,7 @@ class Scenario(LabelMixin, StatusMixin, CommentMixin, GenericAttributeMixin, Bas description = Column(UnicodeText()) summary = Column(UnicodeText()) dfiq_identifier = Column(UnicodeText()) + uuid = Column(UnicodeText()) spec_json = Column(UnicodeText()) sketch_id = Column(Integer, ForeignKey("sketch.id")) user_id = Column(Integer, ForeignKey("user.id")) @@ -639,6 +640,7 @@ class Facet(LabelMixin, StatusMixin, CommentMixin, GenericAttributeMixin, BaseMo display_name = Column(UnicodeText()) description = Column(UnicodeText()) dfiq_identifier = Column(UnicodeText()) + uuid = Column(UnicodeText()) spec_json = Column(UnicodeText()) sketch_id = Column(Integer, ForeignKey("sketch.id")) user_id = Column(Integer, ForeignKey("user.id")) @@ -736,6 +738,7 @@ class InvestigativeQuestion( display_name = Column(UnicodeText()) description = Column(UnicodeText()) dfiq_identifier = Column(UnicodeText()) + uuid = Column(UnicodeText()) spec_json = Column(UnicodeText()) sketch_id = Column(Integer, ForeignKey("sketch.id")) user_id = Column(Integer, ForeignKey("user.id"))