Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Todo: viewset, serializer #666

Merged
merged 27 commits into from Aug 27, 2020
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
dd63ef7
Add Todos bulk creation serializer, viewset and tests
jumasheff Aug 19, 2020
b29cd29
Add TODO
jumasheff Aug 19, 2020
cfe977b
Add project field
jumasheff Aug 20, 2020
550dbca
Add step to serializer
jumasheff Aug 20, 2020
9c43f7f
Make it possible to get todo by pk
jumasheff Aug 20, 2020
2b4c2f3
Add filter for retrieving todos
jumasheff Aug 21, 2020
9d6028b
Implementand test update logic
jumasheff Aug 21, 2020
d42366c
Add partial_update, destroy and bulk_update functionality
jumasheff Aug 21, 2020
084144c
Order by creation date
jumasheff Aug 21, 2020
49a29d1
Rename url
jumasheff Aug 21, 2020
f6fcd6b
Reduce amount of code
jumasheff Aug 24, 2020
e74f1a9
Make sure we have all the fields
jumasheff Aug 25, 2020
3adeff1
Remove id validation logic
jumasheff Aug 25, 2020
c44094d
Simplify get_queryset
jumasheff Aug 25, 2020
d8a3cce
Move the viewset to project_api
jumasheff Aug 25, 2020
6ae4cc1
Require step field on the serializer level
jumasheff Aug 26, 2020
9d2b18f
Reqiure step field on the serializer level
jumasheff Aug 26, 2020
fef6201
Add permission classes
jumasheff Aug 26, 2020
1590b5e
Add create test-case
jumasheff Aug 26, 2020
eaeb1d5
Add a testcase for permission check
jumasheff Aug 26, 2020
9a9976d
Rename and move tests
jumasheff Aug 26, 2020
af0092e
Linting fixes
jumasheff Aug 26, 2020
586f7e5
Remove old comment
jumasheff Aug 26, 2020
72e06e0
Add project/ prefix
jumasheff Aug 26, 2020
2815f07
Fix bulk update
jumasheff Aug 26, 2020
00ac211
Supply non-existent project_id
jumasheff Aug 27, 2020
afe8bb5
Make it more readable
jumasheff Aug 27, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions orchestra/project_api/auth.py
Expand Up @@ -19,6 +19,8 @@ def has_permission(self, request, view):
return request.user and isinstance(request.user, SignedUser)


# TODO(murat): rename and move to common/ since it's being
marcua marked this conversation as resolved.
Show resolved Hide resolved
# used in multiple places
class OrchestraProjectAPIAuthentication(SignatureAuthentication):
API_KEY_HEADER = 'X-Api-Key'

Expand Down
134 changes: 134 additions & 0 deletions orchestra/tests/test_todos.py
Expand Up @@ -15,10 +15,13 @@
from orchestra.tests.helpers.fixtures import TodoFactory
from orchestra.tests.helpers.fixtures import TodoQAFactory
from orchestra.tests.helpers.fixtures import TodoListTemplateFactory
from orchestra.tests.helpers.fixtures import ProjectFactory
from orchestra.tests.helpers.fixtures import StepFactory
from orchestra.tests.helpers.fixtures import setup_models
from orchestra.todos.serializers import TodoSerializer
from orchestra.todos.serializers import TodoQASerializer
from orchestra.todos.serializers import TodoListTemplateSerializer
from orchestra.todos.serializers import BulkTodoSerializer
from orchestra.utils.load_json import load_encoded_json


Expand Down Expand Up @@ -602,3 +605,134 @@ def test_conditional_skip_remove_todos_from_template(self):
]
for todo, expected_todo in zip(todos, expected_todos):
self._verify_todo_content(todo, expected_todo)


class BulkTodoSerializerTests(EndpointTestCase):
marcua marked this conversation as resolved.
Show resolved Hide resolved
marcua marked this conversation as resolved.
Show resolved Hide resolved
def setUp(self):
super().setUp()
setup_models(self)
self.project = ProjectFactory()
self.step = StepFactory()
self.list_url = reverse('orchestra:todos:todo-api-list')
self.todo = TodoFactory(project=self.project)
marcua marked this conversation as resolved.
Show resolved Hide resolved
self.todo_with_step = TodoFactory(project=self.project, step=self.step)

def test_bulk_create(self):
todos = Todo.objects.filter(title__startswith='Testing title ')
self.assertEqual(len(todos), 0)
data = [
{
'title': 'Testing title {}'.format(x),
'project': self.project.id,
'step': self.step.id
} for x in range(10)
]
resp = self.request_client.post(
self.list_url, data=json.dumps(data),
content_type='application/json')
self.assertEqual(resp.status_code, 201)
todos = Todo.objects.filter(
title__startswith='Testing title ',
project=self.project,
step=self.step)
self.assertEqual(len(todos), 10)

def test_get_single_todo_by_pk(self):
detail_url = reverse(
'orchestra:todos:todo-api-detail',
kwargs={'pk': self.todo.id})
resp = self.request_client.get(detail_url)
self.assertEqual(resp.status_code, 200)

def test_get_list_of_todos_with_filters(self):
url_with_project_filter = '{}?project={}'.format(
self.list_url, self.project.id)
resp = self.request_client.get(url_with_project_filter)
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.json()), 2)

url_with_step_filter = '{}?step={}'.format(
self.list_url, self.todo_with_step.step.id)
resp = self.request_client.get(url_with_step_filter)
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.json()), 1)
self.assertEqual(resp.json()[0]['step'], self.todo_with_step.step.id)

url_with_filters = '{}?project={}&step={}'.format(
self.list_url, self.project.id, self.todo_with_step.step.id)
resp = self.request_client.get(url_with_filters)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()[0]['step'], self.todo_with_step.step.id)

def test_update_functionality(self):
todo1 = TodoFactory(
project=self.project, step=self.step, title='Test title1')
todo2 = TodoFactory(
project=self.project, step=self.step, title='Test title2')
# Set title of the todo2 to todo1
serialized = BulkTodoSerializer(todo2).data
detail_url = reverse(
'orchestra:todos:todo-api-detail',
kwargs={'pk': todo1.id})
resp = self.request_client.put(
detail_url,
data=json.dumps(serialized),
content_type='application/json')
self.assertEqual(resp.status_code, 200)

# Check if title is updated
updated_todo_1 = Todo.objects.get(pk=todo1.pk)
self.assertEqual(updated_todo_1.title, todo2.title)

def test_partial_update_functionality(self):
detail_url = reverse(
'orchestra:todos:todo-api-detail',
kwargs={'pk': self.todo.id})
expected_title = 'Partial update title'
resp = self.request_client.patch(
detail_url,
data=json.dumps({'title': expected_title}),
content_type='application/json')
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()['title'], expected_title)

def test_destroy_functionality(self):
all_todos_count = Todo.objects.count()
self.assertEqual(all_todos_count, 2)
detail_url = reverse(
'orchestra:todos:todo-api-detail',
kwargs={'pk': self.todo.id})
resp = self.request_client.delete(detail_url)
self.assertEqual(resp.status_code, 204)
all_todos_count = Todo.objects.count()
self.assertEqual(all_todos_count, 1)

marked_as_deleted = Todo.unsafe_objects.get(pk=self.todo.pk)
self.assertTrue(marked_as_deleted.is_deleted)
self.assertEqual(marked_as_deleted, self.todo)

def test_bulk_update(self):
todo1 = TodoFactory(
project=self.project, step=self.step, title='Test title1')
todo2 = TodoFactory(
project=self.project, step=self.step, title='Test title2')
todo3 = TodoFactory(
project=self.project, step=self.step, title='Test title3')
serialized = BulkTodoSerializer([todo1, todo2, todo3], many=True).data
# Change titles
updated = [
self._change_attr(x, 'title', 'updated title {}'.format(x['id']))
for x in serialized]
resp = self.request_client.put(
self.list_url, data=json.dumps(updated),
content_type='application/json')
self.assertEqual(resp.status_code, 200)

updated_todos = Todo.objects.filter(
id__in=[todo1.id, todo2.id, todo3.id])
for todo in updated_todos:
self.assertTrue(todo.title.startswith('updated title'))

def _change_attr(self, item, attr, value):
item[attr] = value
return item
60 changes: 60 additions & 0 deletions orchestra/todos/serializers.py
@@ -1,4 +1,7 @@
from rest_framework import serializers
from rest_framework.exceptions import ValidationError

from django.db import IntegrityError

from orchestra.models import Todo
from orchestra.models import TodoQA
Expand Down Expand Up @@ -89,3 +92,60 @@ class Meta:
'creator',
'todos')
read_only_fields = ('id',)


class TodoBulkCreateListSerializer(serializers.ListSerializer):
marcua marked this conversation as resolved.
Show resolved Hide resolved
def create(self, validated_data):
result = [self.child.create(attrs) for attrs in validated_data]
marcua marked this conversation as resolved.
Show resolved Hide resolved
try:
self.child.Meta.model.objects.bulk_create(result)
except IntegrityError as e:
raise ValidationError(e)
return result

def update(self, instances, validated_data):
instance_hash = {
marcua marked this conversation as resolved.
Show resolved Hide resolved
index: instance for index, instance in enumerate(instances)}

result = [
self.child.update(instance_hash[index], attrs)
marcua marked this conversation as resolved.
Show resolved Hide resolved
for index, attrs in enumerate(validated_data)
]

writable_fields = [
x for x in self.child.Meta.fields
if x not in self.child.Meta.read_only_fields
]

try:
self.child.Meta.model.objects.bulk_update(result, writable_fields)
except IntegrityError as e:
raise ValidationError(e)

return result


class BulkTodoSerializer(serializers.ModelSerializer):
marcua marked this conversation as resolved.
Show resolved Hide resolved
marcua marked this conversation as resolved.
Show resolved Hide resolved
def create(self, validated_data):
instance = Todo(**validated_data)
if isinstance(self._kwargs['data'], dict):
instance.save()
return instance

def update(self, instance, validated_data):
for k, v in validated_data.items():
setattr(instance, k, v)

if isinstance(self._kwargs['data'], dict):
instance.save()
return instance

class Meta:
model = Todo
fields = (
'id', 'title', 'details', 'section', 'project', 'step',
'order', 'completed', 'start_by_datetime', 'due_datetime',
'skipped_datetime', 'parent_todo', 'template', 'activity_log',
'status', 'additional_data')
read_only_fields = ('id',)
list_serializer_class = TodoBulkCreateListSerializer
8 changes: 8 additions & 0 deletions orchestra/todos/urls.py
@@ -1,4 +1,5 @@
from django.conf.urls import url
from rest_framework import routers

from orchestra.todos.views import TodoDetail
from orchestra.todos.views import TodoList
Expand Down Expand Up @@ -30,3 +31,10 @@
views.worker_task_recent_todo_qas,
name='worker_task_recent_todo_qas'),
]

router = routers.SimpleRouter()
router.register(
marcua marked this conversation as resolved.
Show resolved Hide resolved
r'todo-api', views.TodoListViewset, basename='todo-api'
)

urlpatterns += router.urls
60 changes: 60 additions & 0 deletions orchestra/todos/views.py
Expand Up @@ -3,7 +3,11 @@
from rest_framework import generics
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import ValidationError
marcua marked this conversation as resolved.
Show resolved Hide resolved
from rest_framework.decorators import action
from jsonview.exceptions import BadRequest
from django_filters import rest_framework as filters

from orchestra.models import Task
from orchestra.models import Todo
Expand All @@ -14,12 +18,14 @@
from orchestra.todos.serializers import TodoWithQASerializer
from orchestra.todos.serializers import TodoQASerializer
from orchestra.todos.serializers import TodoListTemplateSerializer
from orchestra.todos.serializers import BulkTodoSerializer
from orchestra.utils.notifications import message_experts_slack_group
from orchestra.todos.api import add_todolist_template
from orchestra.utils.decorators import api_endpoint
from orchestra.todos.auth import IsAssociatedWithTodosProject
from orchestra.todos.auth import IsAssociatedWithProject
from orchestra.todos.auth import IsAssociatedWithTask
from orchestra.project_api.auth import OrchestraProjectAPIAuthentication

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -178,3 +184,57 @@ class TodoListTemplateList(generics.ListCreateAPIView):

serializer_class = TodoListTemplateSerializer
queryset = TodoListTemplate.objects.all()


def validate_ids(data, field='id', unique=True):
marcua marked this conversation as resolved.
Show resolved Hide resolved
if isinstance(data, list):
id_list = [int(x[field]) for x in data]
if unique and len(id_list) != len(set(id_list)):
raise ValidationError(
'Multiple updates to a single {} found'.format(field))
return id_list
return [data]


class TodoListViewset(ModelViewSet):
marcua marked this conversation as resolved.
Show resolved Hide resolved
serializer_class = BulkTodoSerializer
authentication_classes = (OrchestraProjectAPIAuthentication,)
filter_backends = (filters.DjangoFilterBackend,)
marcua marked this conversation as resolved.
Show resolved Hide resolved
filterset_fields = ('project', 'step',)

def get_serializer(self, *args, **kwargs):
if isinstance(kwargs.get('data', {}), list):
kwargs['many'] = True

return super().get_serializer(*args, **kwargs)

def is_single_item_request_by_pk(self):
return (
self.action == 'retrieve' or
self.action == 'update' or
self.action == 'partial_update' or
self.action == 'destroy'
)

def get_queryset(self, ids=None):
marcua marked this conversation as resolved.
Show resolved Hide resolved
queryset = Todo.objects.all()
if ids:
queryset = Todo.objects.filter(id__in=ids)
elif self.is_single_item_request_by_pk():
queryset = Todo.objects.filter(pk=self.kwargs.get('pk'))
queryset = queryset.order_by('-created_at')
return queryset

@action(detail=False, methods=['put'])
def put(self, request, *args, **kwargs):
ids = validate_ids(request.data)
instances = self.get_queryset(ids=ids)
serializer = self.get_serializer(
instances, data=request.data, partial=False, many=True)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
data = serializer.data
return Response(data)

def perform_update(self, serializer):
serializer.save()