diff --git a/taiga/base/utils/db.py b/taiga/base/utils/db.py
index be029ba0..aa629495 100644
--- a/taiga/base/utils/db.py
+++ b/taiga/base/utils/db.py
@@ -46,6 +46,7 @@ def reload_attribute(model_instance, attr_name):
def save_in_bulk(instances, callback=None, **save_options):
"""Save a list of model instances.
+ :params instances: List of model instances.
:params callback: Callback to call after each save.
:params save_options: Additional options to use when saving each instance.
"""
@@ -55,3 +56,34 @@ def save_in_bulk(instances, callback=None, **save_options):
for instance in instances:
instance.save(**save_options)
callback(instance)
+
+
+@transaction.atomic
+def update_in_bulk(instances, list_of_new_values, callback=None):
+ """Update a list of model instances.
+
+ :params instances: List of model instances.
+ :params new_values: List of dicts where each dict is the new data corresponding to the instance
+ in the same index position as the dict.
+ """
+ if callback is None:
+ callback = functions.identity
+
+ for instance, new_values in zip(instances, list_of_new_values):
+ for attribute, value in new_values.items():
+ setattr(instance, attribute, value)
+ instance.save()
+ callback(instance)
+
+
+@transaction.atomic
+def update_in_bulk_with_ids(ids, list_of_new_values, model):
+ """Update a table using a list of ids.
+
+ :params ids: List of ids.
+ :params new_values: List of dicts or duples where each dict/duple is the new data corresponding
+ to the instance in the same index position as the dict.
+ :param model: Model of the ids.
+ """
+ for id, new_values in zip(ids, list_of_new_values):
+ model.objects.filter(id=id).update(**new_values)
diff --git a/taiga/base/utils/text.py b/taiga/base/utils/text.py
index c1d8709b..df9b0259 100644
--- a/taiga/base/utils/text.py
+++ b/taiga/base/utils/text.py
@@ -25,3 +25,8 @@ def strip_lines(text):
output = output.replace("\n", " ")
return output.strip()
+
+
+def split_in_lines(text):
+ """Split a block of text in lines removing unnecessary spaces from each line."""
+ return (line for line in map(str.strip, text.split("\n")) if line)
diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py
index a162fecc..20c7a90d 100644
--- a/taiga/projects/userstories/api.py
+++ b/taiga/projects/userstories/api.py
@@ -79,9 +79,8 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
if request.user != project.owner and not has_project_perm(request.user, project, 'add_userstory'):
raise exc.PermissionDenied(_("You don't have permisions to create user stories."))
- service = services.UserStoriesService()
- user_stories = service.bulk_insert(project, request.user, bulk_stories,
- callback_on_success=self.post_save)
+ user_stories = services.create_userstories_in_bulk(
+ bulk_stories, callback=self.post_save, project=project, owner=request.user)
user_stories_serialized = self.serializer_class(user_stories, many=True)
return Response(data=user_stories_serialized.data)
@@ -107,8 +106,7 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
if request.user != project.owner and not has_project_perm(request.user, project, 'change_userstory'):
raise exc.PermissionDenied(_("You don't have permisions to create user stories."))
- service = services.UserStoriesService()
- service.bulk_update_order(project, request.user, bulk_stories)
+ services.update_userstories_order_in_bulk(bulk_stories)
return Response(data=None, status=status.HTTP_204_NO_CONTENT)
diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py
index de1693da..1aa3ee45 100644
--- a/taiga/projects/userstories/services.py
+++ b/taiga/projects/userstories/services.py
@@ -14,43 +14,47 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from django.db import transaction
-from django.db import connection
+from taiga.base.utils import db, text
from . import models
-class UserStoriesService(object):
- @transaction.atomic
- def bulk_insert(self, project, user, data, callback_on_success=None):
- user_stories = []
+def get_userstories_from_bulk(bulk_data, **additional_fields):
+ """Convert `bulk_data` into a list of user stories.
- items = filter(lambda s: len(s) > 0,
- map(lambda s: s.strip(), data.split("\n")))
+ :param bulk_data: List of user stories in bulk format.
+ :param additional_fields: Additional fields when instantiating each user story.
- for item in items:
- obj = models.UserStory.objects.create(subject=item, project=project, owner=user,
- status=project.default_us_status)
- user_stories.append(obj)
+ :return: List of `UserStory` instances.
+ """
+ return [models.UserStory(subject=line, **additional_fields)
+ for line in text.split_in_lines(bulk_data)]
- if callback_on_success:
- callback_on_success(obj, True)
- return user_stories
+def create_userstories_in_bulk(bulk_data, callback=None, **additional_fields):
+ """Create user stories from `bulk_data`.
- @transaction.atomic
- def bulk_update_order(self, project, user, data):
- # TODO: Create a history snapshot of all updated USs
- cursor = connection.cursor()
+ :param bulk_data: List of user stories in bulk format.
+ :param callback: Callback to execute after each user story save.
+ :param additional_fields: Additional fields when instantiating each user story.
- sql = """
- prepare bulk_update_order as update userstories_userstory set "order" = $1
- where userstories_userstory.id = $2 and
- userstories_userstory.project_id = $3;
- """
+ :return: List of created `Task` instances.
+ """
+ userstories = get_userstories_from_bulk(bulk_data, **additional_fields)
+ db.save_in_bulk(userstories, callback)
+ return userstories
- cursor.execute(sql)
- for usid, usorder in data:
- cursor.execute("EXECUTE bulk_update_order (%s, %s, %s);",
- (usorder, usid, project.id))
- cursor.close()
+
+def update_userstories_order_in_bulk(bulk_data):
+ """Update the order of some user stories.
+
+ `bulk_data` should be a list of tuples with the following format:
+
+ [(, ), ...]
+ """
+ user_story_ids = []
+ new_order_values = []
+ for user_story_id, new_order_value in bulk_data:
+ user_story_ids.append(user_story_id)
+ new_order_values.append({"order": new_order_value})
+ db.update_in_bulk_with_ids(user_story_ids, new_order_values, model=models.UserStory)
diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py
new file mode 100644
index 00000000..1c8d3537
--- /dev/null
+++ b/tests/integration/test_userstories.py
@@ -0,0 +1,39 @@
+from unittest import mock
+
+import pytest
+
+from taiga.projects.userstories import services, models
+
+pytestmark = pytest.mark.django_db
+
+
+def test_get_userstories_from_bulk():
+ data = """
+User Story #1
+User Story #2
+"""
+ userstories = services.get_userstories_from_bulk(data)
+
+ assert len(userstories) == 2
+ assert userstories[0].subject == "User Story #1"
+ assert userstories[1].subject == "User Story #2"
+
+
+@mock.patch("taiga.projects.userstories.services.db")
+def test_create_userstories_in_bulk(db):
+ data = """
+User Story #1
+User Story #2
+"""
+ userstories = services.create_userstories_in_bulk(data)
+
+ db.save_in_bulk.assert_called_once_with(userstories, None)
+
+
+@mock.patch("taiga.projects.userstories.services.db")
+def test_update_userstories_order_in_bulk(db):
+ data = [(1, 1), (2, 2)]
+ services.update_userstories_order_in_bulk(data)
+
+ db.update_in_bulk_with_ids.assert_called_once_with([1, 2], [{"order": 1}, {"order": 2}],
+ model=models.UserStory)
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index b6b871aa..48e279b8 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -3,7 +3,7 @@ from unittest import mock
import django_sites as sites
from taiga.base.utils.urls import get_absolute_url, is_absolute_url, build_url
-from taiga.base.utils.db import save_in_bulk
+from taiga.base.utils.db import save_in_bulk, update_in_bulk, update_in_bulk_with_ids
def test_is_absolute_url():
@@ -35,3 +35,41 @@ def test_save_in_bulk_with_a_callback():
save_in_bulk(instances, callback)
assert callback.call_count == 2
+
+
+def test_update_in_bulk():
+ instance = mock.Mock()
+ instances = [instance, instance]
+ new_values = [{"field1": 1}, {"field2": 2}]
+
+ update_in_bulk(instances, new_values)
+
+ assert instance.save.call_count == 2
+ assert instance.field1 == 1
+ assert instance.field2 == 2
+
+
+def test_update_in_bulk_with_a_callback():
+ instance = mock.Mock()
+ callback = mock.Mock()
+ instances = [instance, instance]
+ new_values = [{"field1": 1}, {"field2": 2}]
+
+ update_in_bulk(instances, new_values, callback)
+
+ assert callback.call_count == 2
+
+
+def test_update_in_bulk_with_ids():
+ ids = [1, 2]
+ new_values = [{"field1": 1}, {"field2": 2}]
+ model = mock.Mock()
+
+ update_in_bulk_with_ids(ids, new_values, model)
+
+ expected_calls = [
+ mock.call(id=1), mock.call().update(field1=1),
+ mock.call(id=2), mock.call().update(field2=2)
+ ]
+
+ model.objects.filter.assert_has_calls(expected_calls)