diff --git a/taiga/base/api/mixins.py b/taiga/base/api/mixins.py
index 27c05675..c558f750 100644
--- a/taiga/base/api/mixins.py
+++ b/taiga/base/api/mixins.py
@@ -53,6 +53,8 @@ from taiga.base import response
from .settings import api_settings
from .utils import get_object_or_404
+from ..decorators import model_pk_lock
+
def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None):
"""
@@ -159,12 +161,15 @@ class UpdateModelMixin:
"""
@tx.atomic
+ @model_pk_lock
def update(self, request, *args, **kwargs):
partial = kwargs.pop('partial', False)
self.object = self.get_object_or_none()
-
self.check_permissions(request, 'update', self.object)
+ if self.object is None:
+ raise Http404
+
serializer = self.get_serializer(self.object, data=request.DATA,
files=request.FILES, partial=partial)
@@ -225,6 +230,7 @@ class DestroyModelMixin:
Destroy a model instance.
"""
@tx.atomic
+ @model_pk_lock
def destroy(self, request, *args, **kwargs):
obj = self.get_object_or_none()
self.check_permissions(request, 'destroy', obj)
diff --git a/taiga/base/decorators.py b/taiga/base/decorators.py
index af496de4..0ffe8e10 100644
--- a/taiga/base/decorators.py
+++ b/taiga/base/decorators.py
@@ -15,11 +15,9 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-import warnings
+from django_pglocks import advisory_lock
-# Rest Framework 2.4 backport some decorators.
-
def detail_route(methods=['get'], **kwargs):
"""
Used to mark a method on a ViewSet that should be routed for detail requests.
@@ -46,35 +44,19 @@ def list_route(methods=['get'], **kwargs):
return decorator
-def link(**kwargs):
+def model_pk_lock(func):
"""
- Used to mark a method on a ViewSet that should be routed for detail GET requests.
+ This decorator is designed to be used in ModelViewsets methods to lock them based
+ on the model and the id of the selected object.
"""
- msg = 'link is pending deprecation. Use detail_route instead.'
- warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
+ def decorator(self, *args, **kwargs):
+ from taiga.base.utils.db import get_typename_for_model_class
+ lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
+ pk = self.kwargs.get(self.pk_url_kwarg, None)
+ tn = get_typename_for_model_class(self.get_queryset().model)
+ key = "{0}:{1}".format(tn, pk)
- def decorator(func):
- func.bind_to_methods = ['get']
- func.detail = True
- func.permission_classes = kwargs.get('permission_classes', [])
- func.kwargs = kwargs
- return func
-
- return decorator
-
-
-def action(methods=['post'], **kwargs):
- """
- Used to mark a method on a ViewSet that should be routed for detail POST requests.
- """
- msg = 'action is pending deprecation. Use detail_route instead.'
- warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
-
- def decorator(func):
- func.bind_to_methods = methods
- func.detail = True
- func.permission_classes = kwargs.get('permission_classes', [])
- func.kwargs = kwargs
- return func
+ with advisory_lock(key) as acquired_key_lock:
+ return func(self, *args, **kwargs)
return decorator
diff --git a/taiga/base/utils/db.py b/taiga/base/utils/db.py
index 2619a545..2e076021 100644
--- a/taiga/base/utils/db.py
+++ b/taiga/base/utils/db.py
@@ -19,6 +19,8 @@ from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.shortcuts import _get_queryset
+from django_pglocks import advisory_lock
+
from . import functions
import re
@@ -116,7 +118,6 @@ def update_in_bulk(instances, list_of_new_values, callback=None, precall=None):
callback(instance)
-@transaction.atomic
def update_in_bulk_with_ids(ids, list_of_new_values, model):
"""Update a table using a list of ids.
@@ -125,8 +126,11 @@ def update_in_bulk_with_ids(ids, list_of_new_values, model):
to the instance in the same index position as the dict.
:param model: Model of the ids.
"""
+ tn = get_typename_for_model_class(model)
for id, new_values in zip(ids, list_of_new_values):
- model.objects.filter(id=id).update(**new_values)
+ key = "{0}:{1}".format(tn, id)
+ with advisory_lock(key) as acquired_key_lock:
+ model.objects.filter(id=id).update(**new_values)
def to_tsquery(term):
diff --git a/taiga/events/signal_handlers.py b/taiga/events/signal_handlers.py
index 360e780d..ac2ec87d 100644
--- a/taiga/events/signal_handlers.py
+++ b/taiga/events/signal_handlers.py
@@ -55,5 +55,6 @@ def on_delete_any_model(sender, instance, **kwargs):
return
sesionid = mw.get_current_session_id()
- events.emit_event_for_model(instance, sessionid=sesionid, type="delete")
-
+
+ emit_event = lambda: events.emit_event_for_model(instance, sessionid=sesionid, type="delete")
+ connection.on_commit(emit_event)
diff --git a/taiga/projects/userstories/apps.py b/taiga/projects/userstories/apps.py
index c86f6a99..4ef7d996 100644
--- a/taiga/projects/userstories/apps.py
+++ b/taiga/projects/userstories/apps.py
@@ -19,51 +19,61 @@ from django.apps import AppConfig
from django.apps import apps
from django.db.models import signals
-from taiga.projects import signals as generic_handlers
-from taiga.projects.custom_attributes import signals as custom_attributes_handlers
-from . import signals as handlers
-
def connect_userstories_signals():
- # Cached prev object version
- signals.pre_save.connect(handlers.cached_prev_us,
- sender=apps.get_model("userstories", "UserStory"),
- dispatch_uid="cached_prev_us")
+ from taiga.projects import signals as generic_handlers
+ from . import signals as handlers
- # Role Points
- signals.post_save.connect(handlers.update_role_points_when_create_or_edit_us,
- sender=apps.get_model("userstories", "UserStory"),
- dispatch_uid="update_role_points_when_create_or_edit_us")
+ # When deleting user stories we must disable task signals while delating and
+ # enabling them in the end
+ signals.pre_delete.connect(handlers.disable_task_signals,
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid='disable_task_signals')
- # Tasks
- signals.post_save.connect(handlers.update_milestone_of_tasks_when_edit_us,
- sender=apps.get_model("userstories", "UserStory"),
- dispatch_uid="update_milestone_of_tasks_when_edit_us")
+ signals.post_delete.connect(handlers.enable_tasks_signals,
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid='enable_tasks_signals')
- # Open/Close US and Milestone
- signals.post_save.connect(handlers.try_to_close_or_open_us_and_milestone_when_create_or_edit_us,
- sender=apps.get_model("userstories", "UserStory"),
- dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_us")
- signals.post_delete.connect(handlers.try_to_close_milestone_when_delete_us,
- sender=apps.get_model("userstories", "UserStory"),
- dispatch_uid="try_to_close_milestone_when_delete_us")
+ # Cached prev object version
+ signals.pre_save.connect(handlers.cached_prev_us,
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="cached_prev_us")
- # Tags
- signals.pre_save.connect(generic_handlers.tags_normalization,
- sender=apps.get_model("userstories", "UserStory"),
- dispatch_uid="tags_normalization_user_story")
- signals.post_save.connect(generic_handlers.update_project_tags_when_create_or_edit_taggable_item,
- sender=apps.get_model("userstories", "UserStory"),
- dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_user_story")
- signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item,
- sender=apps.get_model("userstories", "UserStory"),
- dispatch_uid="update_project_tags_when_delete_taggable_item_user_story")
+ # Role Points
+ signals.post_save.connect(handlers.update_role_points_when_create_or_edit_us,
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="update_role_points_when_create_or_edit_us")
+
+ # Tasks
+ signals.post_save.connect(handlers.update_milestone_of_tasks_when_edit_us,
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="update_milestone_of_tasks_when_edit_us")
+
+ # Open/Close US and Milestone
+ signals.post_save.connect(handlers.try_to_close_or_open_us_and_milestone_when_create_or_edit_us,
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_us")
+ signals.post_delete.connect(handlers.try_to_close_milestone_when_delete_us,
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="try_to_close_milestone_when_delete_us")
+
+ # Tags
+ signals.pre_save.connect(generic_handlers.tags_normalization,
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="tags_normalization_user_story")
+ signals.post_save.connect(generic_handlers.update_project_tags_when_create_or_edit_taggable_item,
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_user_story")
+ signals.post_delete.connect(generic_handlers.update_project_tags_when_delete_taggable_item,
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="update_project_tags_when_delete_taggable_item_user_story")
def connect_userstories_custom_attributes_signals():
- signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_user_story,
- sender=apps.get_model("userstories", "UserStory"),
- dispatch_uid="create_custom_attribute_value_when_create_user_story")
+ from taiga.projects.custom_attributes import signals as custom_attributes_handlers
+ signals.post_save.connect(custom_attributes_handlers.create_custom_attribute_value_when_create_user_story,
+ sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="create_custom_attribute_value_when_create_user_story")
def connect_all_userstories_signals():
@@ -72,18 +82,27 @@ def connect_all_userstories_signals():
def disconnect_userstories_signals():
- signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="cached_prev_us")
- signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="update_role_points_when_create_or_edit_us")
- signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="update_milestone_of_tasks_when_edit_us")
- signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_us")
- signals.post_delete.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="try_to_close_milestone_when_delete_us")
- signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="tags_normalization_user_story")
- signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_user_story")
- signals.post_delete.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="update_project_tags_when_delete_taggable_item_user_story")
+ signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="cached_prev_us")
+ signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="update_role_points_when_create_or_edit_us")
+ signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="update_milestone_of_tasks_when_edit_us")
+ signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="try_to_close_or_open_us_and_milestone_when_create_or_edit_us")
+ signals.post_delete.disconnect(sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="try_to_close_milestone_when_delete_us")
+ signals.pre_save.disconnect(sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="tags_normalization_user_story")
+ signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="update_project_tags_when_create_or_edit_taggable_item_user_story")
+ signals.post_delete.disconnect(sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="update_project_tags_when_delete_taggable_item_user_story")
def disconnect_userstories_custom_attributes_signals():
- signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"), dispatch_uid="create_custom_attribute_value_when_create_user_story")
+ signals.post_save.disconnect(sender=apps.get_model("userstories", "UserStory"),
+ dispatch_uid="create_custom_attribute_value_when_create_user_story")
def disconnect_all_userstories_signals():
diff --git a/taiga/projects/userstories/signals.py b/taiga/projects/userstories/signals.py
index 8a9c66c0..b5afb105 100644
--- a/taiga/projects/userstories/signals.py
+++ b/taiga/projects/userstories/signals.py
@@ -18,6 +18,17 @@
from contextlib import suppress
from django.core.exceptions import ObjectDoesNotExist
from taiga.projects.history.services import take_snapshot
+from taiga.projects.tasks.apps import connect_all_tasks_signals, disconnect_all_tasks_signals
+
+
+# Enable tasks signals
+def enable_tasks_signals(sender, instance, **kwargs):
+ connect_all_tasks_signals()
+
+
+# Disable tasks signals
+def disable_task_signals(sender, instance, **kwargs):
+ disconnect_all_tasks_signals()
####################################
# Signals for cached prev US
diff --git a/tests/integration/resources_permissions/test_storage_resources.py b/tests/integration/resources_permissions/test_storage_resources.py
index 65c095ed..4bc6de2b 100644
--- a/tests/integration/resources_permissions/test_storage_resources.py
+++ b/tests/integration/resources_permissions/test_storage_resources.py
@@ -61,7 +61,7 @@ def test_storage_update(client, data):
storage_data["key"] = "test"
storage_data = json.dumps(storage_data)
results = helper_test_http_method(client, 'put', url, storage_data, users)
- assert results == [401, 200, 201]
+ assert results == [401, 200, 404]
def test_storage_delete(client, data):
@@ -118,4 +118,4 @@ def test_storage_patch(client, data):
patch_data = json.dumps({"value": {"test": "test-value"}})
results = helper_test_http_method(client, 'patch', url, patch_data, users)
- assert results == [401, 200, 201]
+ assert results == [401, 200, 404]
diff --git a/tests/integration/test_userstorage_api.py b/tests/integration/test_userstorage_api.py
index 66d4dd3b..3c754cad 100644
--- a/tests/integration/test_userstorage_api.py
+++ b/tests/integration/test_userstorage_api.py
@@ -144,10 +144,7 @@ def test_update_entries(client):
assert response.status_code == 404
response = client.json.put(reverse("user-storage-detail", args=[form["key"]]),
json.dumps(form))
- assert response.status_code == 201
- response = client.json.get(reverse("user-storage-detail", args=[form["key"]]))
- assert response.status_code == 200
- assert response.data["value"] == form["value"]
+ assert response.status_code == 404
def test_delete_storage_entry(client):
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index edd4a9e6..2cabc548 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -14,6 +14,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
+import pytest
from unittest import mock
@@ -23,6 +24,8 @@ import re
from taiga.base.utils.urls import get_absolute_url, is_absolute_url, build_url
from taiga.base.utils.db import save_in_bulk, update_in_bulk, update_in_bulk_with_ids, to_tsquery
+pytestmark = pytest.mark.django_db
+
def test_is_absolute_url():
assert is_absolute_url("http://domain/path")