Adding endpoint to bulk updating the milestone for user stories
parent
43fafd1f57
commit
b272472396
|
@ -17,7 +17,6 @@
|
||||||
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
@ -37,6 +36,7 @@ from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersVi
|
||||||
from taiga.projects.history.mixins import HistoryResourceMixin
|
from taiga.projects.history.mixins import HistoryResourceMixin
|
||||||
from taiga.projects.occ import OCCResourceMixin
|
from taiga.projects.occ import OCCResourceMixin
|
||||||
from taiga.projects.models import Project, UserStoryStatus
|
from taiga.projects.models import Project, UserStoryStatus
|
||||||
|
from taiga.projects.milestones.models import Milestone
|
||||||
from taiga.projects.history.services import take_snapshot
|
from taiga.projects.history.services import take_snapshot
|
||||||
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
|
from taiga.projects.votes.mixins.viewsets import VotedResourceMixin, VotersViewSetMixin
|
||||||
|
|
||||||
|
@ -86,32 +86,6 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
||||||
|
|
||||||
return serializers.UserStorySerializer
|
return serializers.UserStorySerializer
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
|
||||||
self.object = self.get_object_or_none()
|
|
||||||
project_id = request.DATA.get('project', None)
|
|
||||||
if project_id and self.object and self.object.project.id != project_id:
|
|
||||||
try:
|
|
||||||
new_project = Project.objects.get(pk=project_id)
|
|
||||||
self.check_permissions(request, "destroy", self.object)
|
|
||||||
self.check_permissions(request, "create", new_project)
|
|
||||||
|
|
||||||
sprint_id = request.DATA.get('milestone', None)
|
|
||||||
if sprint_id is not None and new_project.milestones.filter(pk=sprint_id).count() == 0:
|
|
||||||
request.DATA['milestone'] = None
|
|
||||||
|
|
||||||
status_id = request.DATA.get('status', None)
|
|
||||||
if status_id is not None:
|
|
||||||
try:
|
|
||||||
old_status = self.object.project.us_statuses.get(pk=status_id)
|
|
||||||
new_status = new_project.us_statuses.get(slug=old_status.slug)
|
|
||||||
request.DATA['status'] = new_status.id
|
|
||||||
except UserStoryStatus.DoesNotExist:
|
|
||||||
request.DATA['status'] = new_project.default_us_status.id
|
|
||||||
except Project.DoesNotExist:
|
|
||||||
return response.BadRequest(_("The project doesn't exist"))
|
|
||||||
|
|
||||||
return super().update(request, *args, **kwargs)
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
qs = qs.prefetch_related("role_points",
|
qs = qs.prefetch_related("role_points",
|
||||||
|
@ -126,6 +100,17 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
||||||
qs = self.attach_votes_attrs_to_queryset(qs)
|
qs = self.attach_votes_attrs_to_queryset(qs)
|
||||||
return self.attach_watchers_attrs_to_queryset(qs)
|
return self.attach_watchers_attrs_to_queryset(qs)
|
||||||
|
|
||||||
|
def pre_conditions_on_save(self, obj):
|
||||||
|
super().pre_conditions_on_save(obj)
|
||||||
|
|
||||||
|
if obj.milestone and obj.milestone.project != obj.project:
|
||||||
|
raise exc.PermissionDenied(_("You don't have permissions to set this sprint "
|
||||||
|
"to this user story."))
|
||||||
|
|
||||||
|
if obj.status and obj.status.project != obj.project:
|
||||||
|
raise exc.PermissionDenied(_("You don't have permissions to set this status "
|
||||||
|
"to this user story."))
|
||||||
|
|
||||||
def pre_save(self, obj):
|
def pre_save(self, obj):
|
||||||
# This is very ugly hack, but having
|
# This is very ugly hack, but having
|
||||||
# restframework is the only way to do it.
|
# restframework is the only way to do it.
|
||||||
|
@ -155,16 +140,49 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
||||||
|
|
||||||
super().post_save(obj, created)
|
super().post_save(obj, created)
|
||||||
|
|
||||||
def pre_conditions_on_save(self, obj):
|
@transaction.atomic
|
||||||
super().pre_conditions_on_save(obj)
|
def create(self, *args, **kwargs):
|
||||||
|
response = super().create(*args, **kwargs)
|
||||||
|
|
||||||
if obj.milestone and obj.milestone.project != obj.project:
|
# Added comment to the origin (issue)
|
||||||
raise exc.PermissionDenied(_("You don't have permissions to set this sprint "
|
if response.status_code == status.HTTP_201_CREATED and self.object.generated_from_issue:
|
||||||
"to this user story."))
|
self.object.generated_from_issue.save()
|
||||||
|
|
||||||
if obj.status and obj.status.project != obj.project:
|
comment = _("Generating the user story #{ref} - {subject}")
|
||||||
raise exc.PermissionDenied(_("You don't have permissions to set this status "
|
comment = comment.format(ref=self.object.ref, subject=self.object.subject)
|
||||||
"to this user story."))
|
history = take_snapshot(self.object.generated_from_issue,
|
||||||
|
comment=comment,
|
||||||
|
user=self.request.user)
|
||||||
|
|
||||||
|
self.send_notifications(self.object.generated_from_issue, history)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object_or_none()
|
||||||
|
project_id = request.DATA.get('project', None)
|
||||||
|
if project_id and self.object and self.object.project.id != project_id:
|
||||||
|
try:
|
||||||
|
new_project = Project.objects.get(pk=project_id)
|
||||||
|
self.check_permissions(request, "destroy", self.object)
|
||||||
|
self.check_permissions(request, "create", new_project)
|
||||||
|
|
||||||
|
sprint_id = request.DATA.get('milestone', None)
|
||||||
|
if sprint_id is not None and new_project.milestones.filter(pk=sprint_id).count() == 0:
|
||||||
|
request.DATA['milestone'] = None
|
||||||
|
|
||||||
|
status_id = request.DATA.get('status', None)
|
||||||
|
if status_id is not None:
|
||||||
|
try:
|
||||||
|
old_status = self.object.project.us_statuses.get(pk=status_id)
|
||||||
|
new_status = new_project.us_statuses.get(slug=old_status.slug)
|
||||||
|
request.DATA['status'] = new_status.id
|
||||||
|
except UserStoryStatus.DoesNotExist:
|
||||||
|
request.DATA['status'] = new_project.default_us_status.id
|
||||||
|
except Project.DoesNotExist:
|
||||||
|
return response.BadRequest(_("The project doesn't exist"))
|
||||||
|
|
||||||
|
return super().update(request, *args, **kwargs)
|
||||||
|
|
||||||
@list_route(methods=["GET"])
|
@list_route(methods=["GET"])
|
||||||
def filters_data(self, request, *args, **kwargs):
|
def filters_data(self, request, *args, **kwargs):
|
||||||
|
@ -224,6 +242,23 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
||||||
return response.Ok(user_stories_serialized.data)
|
return response.Ok(user_stories_serialized.data)
|
||||||
return response.BadRequest(serializer.errors)
|
return response.BadRequest(serializer.errors)
|
||||||
|
|
||||||
|
@list_route(methods=["POST"])
|
||||||
|
def bulk_update_milestone(self, request, **kwargs):
|
||||||
|
serializer = serializers.UpdateMilestoneBulkSerializer(data=request.DATA)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return response.BadRequest(serializer.errors)
|
||||||
|
|
||||||
|
data = serializer.data
|
||||||
|
project = get_object_or_404(Project, pk=data["project_id"])
|
||||||
|
milestone = get_object_or_404(Milestone, pk=data["milestone_id"])
|
||||||
|
|
||||||
|
self.check_permissions(request, "bulk_update_milestone", project)
|
||||||
|
|
||||||
|
services.update_userstories_milestone_in_bulk(data["bulk_stories"], milestone)
|
||||||
|
services.snapshot_userstories_in_bulk(data["bulk_stories"], request.user)
|
||||||
|
|
||||||
|
return response.NoContent()
|
||||||
|
|
||||||
def _bulk_update_order(self, order_field, request, **kwargs):
|
def _bulk_update_order(self, order_field, request, **kwargs):
|
||||||
serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA)
|
serializer = serializers.UpdateUserStoriesOrderBulkSerializer(data=request.DATA)
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
|
@ -255,23 +290,6 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
|
||||||
def bulk_update_kanban_order(self, request, **kwargs):
|
def bulk_update_kanban_order(self, request, **kwargs):
|
||||||
return self._bulk_update_order("kanban_order", request, **kwargs)
|
return self._bulk_update_order("kanban_order", request, **kwargs)
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def create(self, *args, **kwargs):
|
|
||||||
response = super().create(*args, **kwargs)
|
|
||||||
|
|
||||||
# Added comment to the origin (issue)
|
|
||||||
if response.status_code == status.HTTP_201_CREATED and self.object.generated_from_issue:
|
|
||||||
self.object.generated_from_issue.save()
|
|
||||||
|
|
||||||
comment = _("Generating the user story #{ref} - {subject}")
|
|
||||||
comment = comment.format(ref=self.object.ref, subject=self.object.subject)
|
|
||||||
history = take_snapshot(self.object.generated_from_issue,
|
|
||||||
comment=comment,
|
|
||||||
user=self.request.user)
|
|
||||||
|
|
||||||
self.send_notifications(self.object.generated_from_issue, history)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
class UserStoryVotersViewSet(VotersViewSetMixin, ModelListViewSet):
|
class UserStoryVotersViewSet(VotersViewSetMixin, ModelListViewSet):
|
||||||
permission_classes = (permissions.UserStoryVotersPermission,)
|
permission_classes = (permissions.UserStoryVotersPermission,)
|
||||||
|
|
|
@ -34,6 +34,7 @@ class UserStoryPermission(TaigaResourcePermission):
|
||||||
csv_perms = AllowAny()
|
csv_perms = AllowAny()
|
||||||
bulk_create_perms = IsAuthenticated() & (HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us'))
|
bulk_create_perms = IsAuthenticated() & (HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us'))
|
||||||
bulk_update_order_perms = HasProjectPerm('modify_us')
|
bulk_update_order_perms = HasProjectPerm('modify_us')
|
||||||
|
bulk_update_milestone_perms = HasProjectPerm('modify_us')
|
||||||
upvote_perms = IsAuthenticated() & HasProjectPerm('view_us')
|
upvote_perms = IsAuthenticated() & HasProjectPerm('view_us')
|
||||||
downvote_perms = IsAuthenticated() & HasProjectPerm('view_us')
|
downvote_perms = IsAuthenticated() & HasProjectPerm('view_us')
|
||||||
watch_perms = IsAuthenticated() & HasProjectPerm('view_us')
|
watch_perms = IsAuthenticated() & HasProjectPerm('view_us')
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from taiga.base.api import serializers
|
from taiga.base.api import serializers
|
||||||
|
from taiga.base.api.utils import get_object_or_404
|
||||||
from taiga.base.fields import TagsField
|
from taiga.base.fields import TagsField
|
||||||
from taiga.base.fields import PickledObjectField
|
from taiga.base.fields import PickledObjectField
|
||||||
from taiga.base.fields import PgArrayField
|
from taiga.base.fields import PgArrayField
|
||||||
|
@ -24,8 +25,9 @@ from taiga.base.neighbors import NeighborsSerializerMixin
|
||||||
from taiga.base.utils import json
|
from taiga.base.utils import json
|
||||||
|
|
||||||
from taiga.mdrender.service import render as mdrender
|
from taiga.mdrender.service import render as mdrender
|
||||||
from taiga.projects.validators import ProjectExistsValidator
|
from taiga.projects.models import Project
|
||||||
from taiga.projects.validators import UserStoryStatusExistsValidator
|
from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator
|
||||||
|
from taiga.projects.milestones.validators import SprintExistsValidator
|
||||||
from taiga.projects.userstories.validators import UserStoryExistsValidator
|
from taiga.projects.userstories.validators import UserStoryExistsValidator
|
||||||
from taiga.projects.notifications.validators import WatchersValidator
|
from taiga.projects.notifications.validators import WatchersValidator
|
||||||
from taiga.projects.serializers import BasicUserStoryStatusSerializer
|
from taiga.projects.serializers import BasicUserStoryStatusSerializer
|
||||||
|
@ -142,3 +144,30 @@ class _UserStoryOrderBulkSerializer(UserStoryExistsValidator, serializers.Serial
|
||||||
class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, serializers.Serializer):
|
class UpdateUserStoriesOrderBulkSerializer(ProjectExistsValidator, UserStoryStatusExistsValidator, serializers.Serializer):
|
||||||
project_id = serializers.IntegerField()
|
project_id = serializers.IntegerField()
|
||||||
bulk_stories = _UserStoryOrderBulkSerializer(many=True)
|
bulk_stories = _UserStoryOrderBulkSerializer(many=True)
|
||||||
|
|
||||||
|
|
||||||
|
## Milestone bulk serializers
|
||||||
|
|
||||||
|
class _UserStoryMilestoneBulkSerializer(UserStoryExistsValidator, serializers.Serializer):
|
||||||
|
us_id = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateMilestoneBulkSerializer(ProjectExistsValidator, SprintExistsValidator, serializers.Serializer):
|
||||||
|
project_id = serializers.IntegerField()
|
||||||
|
milestone_id = serializers.IntegerField()
|
||||||
|
bulk_stories = _UserStoryMilestoneBulkSerializer(many=True)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
"""
|
||||||
|
All the userstories and the milestone are from the same project
|
||||||
|
"""
|
||||||
|
user_story_ids = [us["us_id"] for us in data["bulk_stories"]]
|
||||||
|
project = get_object_or_404(Project, pk=data["project_id"])
|
||||||
|
|
||||||
|
if project.user_stories.filter(id__in=user_story_ids).count() != len(user_story_ids):
|
||||||
|
raise serializers.ValidationError("all the user stories must be from the same project")
|
||||||
|
|
||||||
|
if project.milestones.filter(id=data["milestone_id"]).count() != 1:
|
||||||
|
raise serializers.ValidationError("the milestone isn't valid for the project")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
|
@ -91,6 +91,21 @@ def update_userstories_order_in_bulk(bulk_data:list, field:str, project:object):
|
||||||
db.update_in_bulk_with_ids(user_story_ids, new_order_values, model=models.UserStory)
|
db.update_in_bulk_with_ids(user_story_ids, new_order_values, model=models.UserStory)
|
||||||
|
|
||||||
|
|
||||||
|
def update_userstories_milestone_in_bulk(bulk_data:list, milestone:object):
|
||||||
|
"""
|
||||||
|
Update the milestone of some user stories.
|
||||||
|
`bulk_data` should be a list of user story ids:
|
||||||
|
"""
|
||||||
|
user_story_ids = [us_data["us_id"] for us_data in bulk_data]
|
||||||
|
new_milestone_values = [{"milestone": milestone.id}] * len(user_story_ids)
|
||||||
|
|
||||||
|
events.emit_event_for_ids(ids=user_story_ids,
|
||||||
|
content_type="userstories.userstory",
|
||||||
|
projectid=milestone.project.pk)
|
||||||
|
|
||||||
|
db.update_in_bulk_with_ids(user_story_ids, new_milestone_values, model=models.UserStory)
|
||||||
|
|
||||||
|
|
||||||
def snapshot_userstories_in_bulk(bulk_data, user):
|
def snapshot_userstories_in_bulk(bulk_data, user):
|
||||||
user_story_ids = []
|
user_story_ids = []
|
||||||
for us_data in bulk_data:
|
for us_data in bulk_data:
|
||||||
|
|
|
@ -164,6 +164,74 @@ def test_api_update_orders_in_bulk(client):
|
||||||
assert response3.status_code == 204, response3.data
|
assert response3.status_code == 204, response3.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_update_milestone_in_bulk(client):
|
||||||
|
project = f.create_project()
|
||||||
|
f.MembershipFactory.create(project=project, user=project.owner, is_admin=True)
|
||||||
|
us1 = f.create_userstory(project=project)
|
||||||
|
us2 = f.create_userstory(project=project)
|
||||||
|
milestone = f.MilestoneFactory.create(project=project)
|
||||||
|
|
||||||
|
url = reverse("userstories-bulk-update-milestone")
|
||||||
|
data = {
|
||||||
|
"project_id": project.id,
|
||||||
|
"milestone_id": milestone.id,
|
||||||
|
"bulk_stories": [{"us_id": us1.id},
|
||||||
|
{"us_id": us2.id}]
|
||||||
|
}
|
||||||
|
|
||||||
|
client.login(project.owner)
|
||||||
|
|
||||||
|
assert project.milestones.get(id=milestone.id).user_stories.count() == 0
|
||||||
|
response = client.json.post(url, json.dumps(data))
|
||||||
|
assert response.status_code == 204, response.data
|
||||||
|
assert project.milestones.get(id=milestone.id).user_stories.count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_update_milestone_in_bulk_invalid_milestone(client):
|
||||||
|
project = f.create_project()
|
||||||
|
f.MembershipFactory.create(project=project, user=project.owner, is_admin=True)
|
||||||
|
us1 = f.create_userstory(project=project)
|
||||||
|
us2 = f.create_userstory(project=project)
|
||||||
|
m1 = f.MilestoneFactory.create(project=project)
|
||||||
|
m2 = f.MilestoneFactory.create()
|
||||||
|
|
||||||
|
url = reverse("userstories-bulk-update-milestone")
|
||||||
|
data = {
|
||||||
|
"project_id": project.id,
|
||||||
|
"milestone_id": m2.id,
|
||||||
|
"bulk_stories": [{"us_id": us1.id},
|
||||||
|
{"us_id": us2.id}]
|
||||||
|
}
|
||||||
|
|
||||||
|
client.login(project.owner)
|
||||||
|
|
||||||
|
response = client.json.post(url, json.dumps(data))
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.data["non_field_errors"][0] == "the milestone isn't valid for the project"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_update_milestone_in_bulk_invalid_userstories(client):
|
||||||
|
project = f.create_project()
|
||||||
|
f.MembershipFactory.create(project=project, user=project.owner, is_admin=True)
|
||||||
|
us1 = f.create_userstory(project=project)
|
||||||
|
us2 = f.create_userstory()
|
||||||
|
milestone = f.MilestoneFactory.create(project=project)
|
||||||
|
|
||||||
|
url = reverse("userstories-bulk-update-milestone")
|
||||||
|
data = {
|
||||||
|
"project_id": project.id,
|
||||||
|
"milestone_id": milestone.id,
|
||||||
|
"bulk_stories": [{"us_id": us1.id},
|
||||||
|
{"us_id": us2.id}]
|
||||||
|
}
|
||||||
|
|
||||||
|
client.login(project.owner)
|
||||||
|
|
||||||
|
response = client.json.post(url, json.dumps(data))
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.data["non_field_errors"][0] == "all the user stories must be from the same project"
|
||||||
|
|
||||||
|
|
||||||
def test_update_userstory_points(client):
|
def test_update_userstory_points(client):
|
||||||
user1 = f.UserFactory.create()
|
user1 = f.UserFactory.create()
|
||||||
user2 = f.UserFactory.create()
|
user2 = f.UserFactory.create()
|
||||||
|
|
Loading…
Reference in New Issue