diff --git a/orchestra/api_urls.py b/orchestra/api_urls.py index baf00ada5..a605c2d53 100644 --- a/orchestra/api_urls.py +++ b/orchestra/api_urls.py @@ -9,6 +9,8 @@ from orchestra.project_api.views import workflow_types from orchestra.project_api.views import message_project_team from orchestra.project_api.views import TodoApiViewset +from orchestra.project_api.views import TodoTemplatesList +from orchestra.project_api.views import create_todos_from_template from orchestra.views import TimeEntryDetail from orchestra.views import TimeEntryList from orchestra.views import dashboard_tasks @@ -87,6 +89,12 @@ url(r'^project/message_project_team', message_project_team, name='message_project_team'), + url(r'^project/todo_templates/$', + TodoTemplatesList.as_view(), + name='todo_templates'), + url(r'^project/create_todos_from_template/$', + create_todos_from_template, + name='create_todos_from_template'), ] router = routers.SimpleRouter() diff --git a/orchestra/orchestra_api.py b/orchestra/orchestra_api.py index 9c1266c26..f712665ff 100644 --- a/orchestra/orchestra_api.py +++ b/orchestra/orchestra_api.py @@ -151,3 +151,21 @@ def message_project_team(project_id, message): response = _make_api_request('post', 'message_project_team', data=json.dumps(data)) return json.loads(response.text) + + +def get_todo_templates(): + response = _make_api_request('get', 'todo_templates') + return json.loads(response.text) + + +def create_todos_from_template(todolist_template_slug, project_id, + step_slug, additional_data): + data = { + 'todolist_template_slug': todolist_template_slug, + 'project_id': project_id, + 'step_slug': step_slug, + 'additional_data': additional_data, + } + response = _make_api_request('post', 'create_todos_from_template', + data=json.dumps(data)) + return json.loads(response.text) diff --git a/orchestra/project_api/api.py b/orchestra/project_api/api.py index b718599bf..0daf3d6fe 100644 --- a/orchestra/project_api/api.py +++ b/orchestra/project_api/api.py @@ -110,7 +110,9 @@ def _traverse_step_graph(graph, workflow_version): current_step = Step.objects.get( workflow_version=workflow_version, slug=current_node) - steps.append({'slug': current_node, + steps.append({ + 'id': current_step.id, + 'slug': current_node, 'description': current_step.description, 'is_human': current_step.is_human, 'name': current_step.name}) diff --git a/orchestra/project_api/views.py b/orchestra/project_api/views.py index 8f4eaeefc..66cb193ab 100644 --- a/orchestra/project_api/views.py +++ b/orchestra/project_api/views.py @@ -4,12 +4,15 @@ from django.urls import reverse from jsonview.exceptions import BadRequest +from rest_framework import generics from orchestra.core.errors import TaskAssignmentError from orchestra.core.errors import WorkerCertificationError from orchestra.models import Project from orchestra.models import WorkerCertification from orchestra.models import Workflow +from orchestra.models import Todo +from orchestra.models import TodoListTemplate from orchestra.project import create_project_with_tasks from orchestra.project_api.api import get_project_information from orchestra.utils.decorators import api_endpoint @@ -19,6 +22,9 @@ from orchestra.project_api.auth import OrchestraProjectAPIAuthentication from orchestra.project_api.auth import IsSignedUser from orchestra.todos.views import GenericTodoViewset +from orchestra.todos.serializers import TodoListTemplateSerializer +from orchestra.todos.serializers import BulkTodoSerializerWithoutQA +from orchestra.todos.api import add_todolist_template logger = logging.getLogger(__name__) @@ -157,6 +163,54 @@ def message_project_team(request): return {'success': True} +@api_endpoint(methods=['POST'], + permissions=(IsSignedUser,), + logger=logger, + auths=(OrchestraProjectAPIAuthentication,)) +def create_todos_from_template(request): + """ + Endpoint for creating todos in a project. + Payload example: + { + 'todolist_template_slug': 'some-template-slug-123', + 'step_slug': 'some-step-slug-123', + 'project_id': 'some-project-id-123' + 'additional_data': { + 'some_key': 'some_value' + } + } + """ + data = load_encoded_json(request.body) + try: + todolist_template_slug = data.get('todolist_template_slug') + step_slug = data.get('step_slug') + project_id = data.get('project_id') + additional_data = data.get('additional_data') + if step_slug and project_id and todolist_template_slug: + add_todolist_template(todolist_template_slug, project_id, + step_slug, additional_data) + todos = Todo.objects.filter( + template__slug=todolist_template_slug, + project__id=project_id, + step__slug=step_slug).order_by('-created_at') + serializer = BulkTodoSerializerWithoutQA(todos, many=True) + return { + 'success': True, + 'todos': serializer.data + } + else: + text = ('An object with `template_slug`, `step_slug`,' + ' and `project_id` attributes should be supplied') + raise Exception(text) + except Exception as e: + return { + 'success': False, + 'errors': { + 'error': str(e) + } + } + + class TodoApiViewset(GenericTodoViewset): """ This viewset inherits from GenericTodoViewset and used by @@ -164,3 +218,11 @@ class TodoApiViewset(GenericTodoViewset): """ permission_classes = (IsSignedUser,) authentication_classes = (OrchestraProjectAPIAuthentication,) + + +class TodoTemplatesList(generics.ListAPIView): + permission_classes = (IsSignedUser,) + authentication_classes = (OrchestraProjectAPIAuthentication,) + + serializer_class = TodoListTemplateSerializer + queryset = TodoListTemplate.objects.all() diff --git a/orchestra/tests/test_project_api_client.py b/orchestra/tests/test_project_api_client.py index 041ef4f3b..04fc2a016 100644 --- a/orchestra/tests/test_project_api_client.py +++ b/orchestra/tests/test_project_api_client.py @@ -6,17 +6,198 @@ from orchestra.models import Todo from orchestra.tests.helpers.fixtures import TodoFactory +from orchestra.tests.helpers.fixtures import TodoListTemplateFactory from orchestra.tests.helpers.fixtures import StepFactory from orchestra.tests.helpers.fixtures import ProjectFactory from orchestra.tests.helpers.fixtures import WorkflowVersionFactory from orchestra.project_api.auth import SignedUser from orchestra.orchestra_api import create_todos from orchestra.orchestra_api import get_todos +from orchestra.orchestra_api import get_todo_templates +from orchestra.orchestra_api import create_todos_from_template from orchestra.orchestra_api import update_todos from orchestra.orchestra_api import delete_todos from orchestra.orchestra_api import OrchestraError +class TodoTemplatesAPITests(TestCase): + def setUp(self): + super().setUp() + self.request_client = APIClient(enforce_csrf_checks=True) + self.request_client.force_authenticate(user=SignedUser()) + + self.todolist_template_slug = 'test_todolist_template_slug' + self.todolist_template_name = 'test_todolist_template_name' + self.todolist_template_description = \ + 'test_todolist_template_description' + self.workflow_version = WorkflowVersionFactory() + self.step = StepFactory( + slug='step-slug', + workflow_version=self.workflow_version) + self.project = ProjectFactory( + workflow_version=self.workflow_version) + + @patch('orchestra.orchestra_api.requests') + def test_get_todo_templates(self, mock_request): + # This converts `requests.get` into DRF's `APIClient.get` + # To make it testable + def get(url, *args, **kwargs): + return_value = self.request_client.get(url, format='json') + return_value.text = json.dumps(return_value.data) + return return_value + + mock_request.get = get + + template1 = TodoListTemplateFactory() + template2 = TodoListTemplateFactory() + + # Get template1 and template2 + res = get_todo_templates() + self.assertEqual(len(res), 2) + expected_ids = [template1.id, template2.id] + for r in res: + self.assertIn(r['id'], expected_ids) + + # # Get newly created template3 + template3 = TodoListTemplateFactory() + res = get_todo_templates() + self.assertEqual(len(res), 3) + self.assertEqual(res[2]['id'], template3.id) + + @patch('orchestra.orchestra_api.requests') + def test_create_todos_from_template(self, mock_request): + # This converts `requests.post` into DRF's `APIClient.post` + # To make it testable + def post(url, *args, **kwargs): + kw = kwargs.get('data', '') + data = json.loads(kw) + return_value = self.request_client.post(url, data, format='json') + return_value.text = json.dumps(return_value.json()) + return return_value + + todolist_template = TodoListTemplateFactory( + slug=self.todolist_template_slug, + name=self.todolist_template_name, + description=self.todolist_template_description, + todos={'items': [{ + 'id': 1, + 'description': 'todo parent', + 'project': self.project.id, + 'step': self.step.slug, + 'items': [{ + 'id': 2, + 'project': self.project.id, + 'step': self.step.slug, + 'description': 'todo child', + 'items': [] + }] + }]}, + ) + + mock_request.post = post + additional_data = { + 'some_additional_data': 'value' + } + result = create_todos_from_template( + self.todolist_template_slug, + self.project.id, + self.step.slug, + additional_data) + self.assertEqual(result['success'], True) + self.assertEqual(len(result['todos']), 3) + for t in result['todos']: + self.assertEqual(t['template'], todolist_template.id) + self.assertEqual(t['section'], None) + self.assertEqual(t['additional_data'], additional_data) + + @patch('orchestra.orchestra_api.requests') + def test_create_todos_from_template_key_error(self, mock_request): + # This converts `requests.post` into DRF's `APIClient.post` + # To make it testable + def post(url, *args, **kwargs): + kw = kwargs.get('data', '') + data = json.loads(kw) + return_value = self.request_client.post(url, data, format='json') + return_value.text = json.dumps(return_value.json()) + return return_value + + TodoListTemplateFactory( + slug=self.todolist_template_slug, + name=self.todolist_template_name, + description=self.todolist_template_description, + todos={'items': [{ + 'id': 1, + 'description': 'todo parent', + 'project': self.project.id, + 'step': self.step.slug, + 'items': [{ + 'id': 2, + 'project': self.project.id, + 'step': self.step.slug, + 'description': 'todo child', + 'items': [] + }] + }]}, + ) + + mock_request.post = post + additional_data = { + 'some_additional_data': 'value' + } + result = create_todos_from_template( + self.todolist_template_slug, + self.project.id, + None, + additional_data) + err_msg = ('An object with `template_slug`, `step_slug`,' + ' and `project_id` attributes should be supplied') + self.assertEqual(result['success'], False) + self.assertEqual(len(result['errors']), 1) + self.assertEqual(result['errors']['error'], err_msg) + + @patch('orchestra.orchestra_api.requests') + def test_create_todos_from_template_unknown_step_slug(self, mock_request): + # This converts `requests.post` into DRF's `APIClient.post` + # To make it testable + def post(url, *args, **kwargs): + kw = kwargs.get('data', '') + data = json.loads(kw) + return_value = self.request_client.post(url, data, format='json') + return_value.text = json.dumps(return_value.json()) + return return_value + + TodoListTemplateFactory( + slug=self.todolist_template_slug, + name=self.todolist_template_name, + description=self.todolist_template_description, + todos={'items': [{ + 'id': 1, + 'description': 'todo parent', + 'project': self.project.id, + 'step': self.step.slug, + 'items': [{ + 'id': 2, + 'project': self.project.id, + 'step': self.step.slug, + 'description': 'todo child', + 'items': [] + }] + }]}, + ) + + mock_request.post = post + additional_data = { + 'some_additional_data': 'value' + } + result = create_todos_from_template( + self.todolist_template_slug, + self.project.id, + 'unknown-step-slug', + additional_data) + self.assertEqual(result['success'], False) + self.assertEqual(len(result['errors']), 1) + + class TodoAPITests(TestCase): def setUp(self): super().setUp() diff --git a/orchestra/tests/test_todos.py b/orchestra/tests/test_todos.py index c358f2615..dacded6c9 100644 --- a/orchestra/tests/test_todos.py +++ b/orchestra/tests/test_todos.py @@ -42,6 +42,12 @@ def _todo_data(title, completed, due=None, parent_todo=None, template=None, activity_log=str({'actions': []}), qa=None, project=None, step=None, details=None, is_deleted=False): + if skipped_datetime: + status = Todo.Status.DECLINED.value + elif completed: + status = Todo.Status.COMPLETED.value + else: + status = Todo.Status.PENDING.value return { 'completed': completed, 'title': title, @@ -56,7 +62,7 @@ def _todo_data(title, completed, 'order': None, 'project': project, 'section': None, - 'status': None, + 'status': status, 'step': step, 'details': details, 'is_deleted': is_deleted @@ -137,7 +143,8 @@ def _verify_todo_creation(self, success, project, step, mock_notify): resp = self.request_client.post(self.list_create_url, { 'project': project, 'step': step.slug, - 'title': self.todo_title}) + 'title': self.todo_title, + 'status': Todo.Status.PENDING.value}) if success: self.assertEqual(resp.status_code, 201) self.assertEqual(Todo.objects.all().count(), num_todos + 1) @@ -210,7 +217,8 @@ def test_create_todo_with_start_by_datetime(self): project=self.project, step=self.step, start_by_datetime=self.deadline, - title=START_TITLE) + title=START_TITLE, + status=Todo.Status.PENDING.value) self._verify_todos_list(start_by_todo.project.id, [ _todo_data( @@ -230,7 +238,8 @@ def test_create_todo_with_due_datetime(self): project=self.project, step=self.step, due_datetime=self.deadline, - title=DUE_TITLE) + title=DUE_TITLE, + status=Todo.Status.PENDING.value) self._verify_todos_list(due_todo.project.id, [ _todo_data( diff --git a/orchestra/todos/api.py b/orchestra/todos/api.py index 424695dfc..ac8f25d87 100644 --- a/orchestra/todos/api.py +++ b/orchestra/todos/api.py @@ -22,18 +22,22 @@ @transaction.atomic -def add_todolist_template(todolist_template_slug, project_id, step_slug): +def add_todolist_template(todolist_template_slug, project_id, + step_slug, additional_data=None): todolist_template = TodoListTemplate.objects.get( slug=todolist_template_slug) project = Project.objects.get(id=project_id) step = get_step_by_project_id_and_step_slug(project_id, step_slug) template_todos = todolist_template.todos.get('items', []) + additional_data = additional_data if additional_data else {} root_todo = Todo( project=project, step=step, title=todolist_template.name, - template=todolist_template + template=todolist_template, + additional_data=additional_data, + status=Todo.Status.PENDING.value ) root_todo.save() @@ -49,7 +53,7 @@ def add_todolist_template(todolist_template_slug, project_id, step_slug): for template_todo in template_todos: _add_template_todo( template_todo, todolist_template, - root_todo, project, step, cond_props) + root_todo, project, step, cond_props, additional_data) def _to_exclude(props, conditions): @@ -75,26 +79,33 @@ def _to_exclude(props, conditions): def _add_template_todo( template_todo, todolist_template, - parent_todo, project, step, conditional_props): + parent_todo, project, step, conditional_props, + additional_data): remove = _to_exclude(conditional_props, template_todo.get('remove_if', [])) if not remove: if parent_todo.skipped_datetime: skipped_datetime = parent_todo.skipped_datetime else: to_skip = _to_exclude( - conditional_props, template_todo.get('skip_if', [])) + conditional_props, template_todo.get('skip_if', [])) skipped_datetime = timezone.now() if to_skip else None + if skipped_datetime: + status = Todo.Status.DECLINED.value + else: + status = Todo.Status.PENDING.value todo = Todo( project=project, step=step, title=template_todo['description'], template=todolist_template, parent_todo=parent_todo, - skipped_datetime=skipped_datetime + skipped_datetime=skipped_datetime, + status=status, + additional_data=additional_data ) todo.save() for template_todo_item in template_todo.get('items', []): _add_template_todo( template_todo_item, todolist_template, todo, - project, step, conditional_props) + project, step, conditional_props, additional_data)