diff --git a/setup.py b/setup.py index 2bafad9..a5d47e5 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import setup, find_packages -VERSION = '1.3.1' +VERSION = '1.4.0' with open('requirements.txt') as f: requirements = f.read().splitlines() diff --git a/surge/__init__.py b/surge/__init__.py index b2ebcac..4c20f0e 100644 --- a/surge/__init__.py +++ b/surge/__init__.py @@ -1,6 +1,7 @@ import os from surge.projects import Project +from surge.blueprints import Blueprint from surge.tasks import Task from surge.teams import Team from surge.reports import Report diff --git a/surge/blueprints.py b/surge/blueprints.py new file mode 100644 index 0000000..4f9886e --- /dev/null +++ b/surge/blueprints.py @@ -0,0 +1,94 @@ +import re +from surge.projects import Project +from surge.errors import SurgeMissingAttributeError + + +class Blueprint(Project): + def __int__(self, **kwargs): + super().__init__(kwargs) + + @classmethod + def _from_project(cls, project): + '''Converts a Project to a Blueprint. This is useful when we want to create a Project and then return + a Blueprint to the user. + + Arguments: + project (Project): The project to convert to a Blueprint + + Returns: + blueprint (Blueprint): A Blueprint derived from the given Project + ''' + kwargs = project.__dict__ + # A Project object will have already parsed created_at, so omit that and save current value to avoid errors. + created_at = kwargs.pop('created_at', None) + b = Blueprint(**project.__dict__) + b.created_at = created_at + return b + + def required_data_fields(self): + ''' + Returns all keys surrounded by {{}} from the fields_template string. + + Returns: + matches (list): all the required data fields from fields_template + e.g. ['field1', 'field2'] + ''' + if not self.fields_template: + return [] + pattern = r"{{(.*?)}}" + matches = re.findall(pattern, self.fields_template) + return matches + + def create_new_batch(self, name): + ''' + Create a new project from this blueprint. Once created, call create_tasks on the returned object + to assign tasks. + + Arguments: + name (str): Name of the project + + Returns: + blueprint (Blueprint): a created project from a blueprint project which can be assigned tasks and launched. + ''' + if not name: + raise SurgeMissingAttributeError('name is required when creating a project from a template') + + create_params = {'template_id': self.id, 'name': name} + project = Project.create(**create_params) + blueprint = Blueprint._from_project(project) + return blueprint + + def create_tasks(self, tasks_data: list, launch=False): + '''Create tasks for this Blueprint project. Ensures that task_data contains fields referenced in + fields_template. + + Arguments: + tasks_data (list): List of dicts. Each dict is key/value pairs for populating fields_template. + + Returns: + tasks (list): list of Tasks objects + ''' + Blueprint._validate_fields_data(self.required_data_fields(), tasks_data) + return super().create_tasks(tasks_data, launch) + + @classmethod + def _validate_fields_data(cls, required_fields, tasks_data): + ''' + Checks the keys in task_data exist in required_fields. + NOTE: this assumes tasks_data is a flat dict (no nested keys). + + Arguments: + required_fields (list): list of required field names + tasks_data (list) list of dicts of task_data. All keys in these dicts should exist in required_fields + + Returns: + None. Only raises if there are required fields missing. + ''' + missing_keys = [] + for data in tasks_data: + diff = set(required_fields) - set(data) + if diff: + missing_keys.append(diff) + if missing_keys: + msg = f'task_data is missing required keys: {missing_keys}' + raise SurgeMissingAttributeError(msg) diff --git a/surge/projects.py b/surge/projects.py index 989eabc..6e9f170 100644 --- a/surge/projects.py +++ b/surge/projects.py @@ -160,11 +160,14 @@ def list_blueprints(cls): Lists blueprint projects for your organization. Returns: - projects (list): list of Project objects. + projects (list): list of Blueprint objects. ''' endpoint = f"{PROJECTS_ENDPOINT}/blueprints" response_json = cls.get(endpoint) - projects = [cls(**project_json) for project_json in response_json] + + # Avoid circular dependency with deferred import. + from surge import Blueprint + projects = [Blueprint(**project_json) for project_json in response_json] return projects @classmethod diff --git a/tests/test_blueprint.py b/tests/test_blueprint.py new file mode 100644 index 0000000..e8401a6 --- /dev/null +++ b/tests/test_blueprint.py @@ -0,0 +1,78 @@ +from datetime import datetime +from dateutil.tz import tzutc +import unittest + +from surge.errors import SurgeMissingAttributeError +from surge.api_resource import APIResource +from surge.projects import Project +from surge.blueprints import Blueprint + + +class BlueprintTests(unittest.TestCase): + '''Use unittest in this class to assert on error messages.''' + + def test_validate_fields_data_fail(self): + b = Blueprint(id=id, + name='name_blueprint', + created_at='2021-01-22T19:49:03.185Z') + b.fields_template = '


' + key = 'video' + with self.assertRaises(SurgeMissingAttributeError) as context: + Blueprint._validate_fields_data(b.required_data_fields(), [{'foo': 'task'}]) + self.assertTrue(key in str(context.exception), f'error message {context.exception} should contain {key}') + + +def test_validate_fields_data(): + # TODO: more test coverage + b = Blueprint(id=id, + name='name_blueprint', + created_at='2021-01-22T19:49:03.185Z') + b.fields_template = '


' + + key = 'video' + Blueprint._validate_fields_data(b.required_data_fields(), [{key: 'task'}]) + + +def test_required_data_fields(): + '''Ensure a Blueprint object knows how to find required fields in fields_template.''' + assert_required_data_fields(None, []) + assert_required_data_fields('', []) + assert_required_data_fields('foo', []) + assert_required_data_fields('{{one}}', ['one']) + assert_required_data_fields('{{video}}{{audio}}{{spacial}}', ["video", "audio", "spacial"]) + assert_required_data_fields('


', ['video']) + + +def test_init_basic(): + project_id = "ABC1234" + name = "Hello World Blueprint" + blueprint = Blueprint(id=project_id, + name=name, + created_at='2021-01-22T19:49:03.185Z') + + assert isinstance(blueprint, APIResource) + assert isinstance(blueprint, Project) + assert isinstance(blueprint, Blueprint) + assert blueprint.id == project_id + assert blueprint.name == name + assert blueprint.created_at == datetime(2021, + 1, + 22, + 19, + 49, + 3, + 185000, + tzinfo=tzutc()) + + +def assert_required_data_fields(fields_template, expected_fields): + blueprint = Blueprint(id="id1", + name="name1", + created_at='2021-01-22T19:49:03.185Z') + blueprint.fields_template = fields_template + assert_values_in(blueprint.required_data_fields(), expected_fields) + + +def assert_values_in(ary, expected): + for value in expected: + assert value in ary diff --git a/tests/test_projects.py b/tests/test_projects.py index 33c7db6..19ee5bc 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -117,6 +117,7 @@ def test_init_complete(): }], 'hidden_by_item_option_id': None, 'shown_by_item_option_id': None, + 'holistic': False }] } @@ -206,6 +207,7 @@ def test_convert_questions_to_objects(): }], 'hidden_by_item_option_id': None, 'shown_by_item_option_id': None, + 'holistic': False }, { 'id': 'c4b0d6a9-f735-40c1-9b42-0414945ef2db', @@ -241,6 +243,7 @@ def test_convert_questions_to_objects(): }], 'hidden_by_item_option_id': None, 'shown_by_item_option_id': None, + 'holistic': False }, { 'id': '6123463e-349e-4450-80d2-6684a28755b3', 'text': 'Free response for {{url}}', @@ -253,6 +256,7 @@ def test_convert_questions_to_objects(): 'options_objects': [], 'hidden_by_item_option_id': None, 'shown_by_item_option_id': None, + 'holistic': False }, { 'id': 'c46e2714-9bf6-44a8-aac3-f01f9fec8ae2', @@ -297,6 +301,7 @@ def test_convert_questions_to_objects(): }], 'hidden_by_item_option_id': None, 'shown_by_item_option_id': None, + 'holistic': False }, { 'id': '6123463e-349e-4450-80d2-6684a28755b4', 'text': 'Text area for {{url}}', @@ -306,6 +311,7 @@ def test_convert_questions_to_objects(): 'options_objects': [], 'hidden_by_item_option_id': None, 'shown_by_item_option_id': None, + 'holistic': True }, { 'id': '6123463e-349e-4450-80d2-6684a28755b5', 'text': 'Chatbot for {{url}}', @@ -317,6 +323,7 @@ def test_convert_questions_to_objects(): 'preexisting_annotations': None, 'hidden_by_item_option_id': None, 'shown_by_item_option_id': None, + 'holistic': True }] project = Project(id="ABC1234", name="Hello World")