diff --git a/taiga/export_import/serializers/serializers.py b/taiga/export_import/serializers/serializers.py
index 555ba2ce..c74bbd0a 100644
--- a/taiga/export_import/serializers/serializers.py
+++ b/taiga/export_import/serializers/serializers.py
@@ -237,6 +237,7 @@ class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin,
role_points = RolePointsExportSerializer(many=True)
owner = UserRelatedField()
assigned_to = UserRelatedField()
+ assigned_users = MethodField()
status = SlugRelatedField(slug_field="name")
milestone = SlugRelatedField(slug_field="name")
modified_date = DateTimeField()
@@ -273,6 +274,10 @@ class UserStoryExportSerializer(CustomAttributesValuesExportSerializerMixin,
_userstories_statuses_cache[project.id] = {s.id: s.name for s in project.us_statuses.all()}
return _userstories_statuses_cache[project.id]
+ def get_assigned_users(self, obj):
+ return [user.email for user in obj.assigned_users.all()]
+
+
class EpicRelatedUserStoryExportSerializer(RelatedExportSerializer):
user_story = SlugRelatedField(slug_field="ref")
order = Field()
diff --git a/taiga/export_import/validators/fields.py b/taiga/export_import/validators/fields.py
index fda4ad5d..aca214b3 100644
--- a/taiga/export_import/validators/fields.py
+++ b/taiga/export_import/validators/fields.py
@@ -159,6 +159,7 @@ class HistorySnapshotField(JSONField):
return data
+
class HistoryUserField(JSONField):
def from_native(self, data):
if data is None:
diff --git a/taiga/export_import/validators/validators.py b/taiga/export_import/validators/validators.py
index c66edaa1..7f0d5fe3 100644
--- a/taiga/export_import/validators/validators.py
+++ b/taiga/export_import/validators/validators.py
@@ -302,6 +302,7 @@ class UserStoryExportValidator(WatcheableObjectModelValidatorMixin):
role_points = RolePointsExportValidator(many=True, required=False)
owner = UserRelatedField(required=False)
assigned_to = UserRelatedField(required=False)
+ assigned_users = UserRelatedField(many=True, required=False)
status = ProjectRelatedField(slug_field="name")
milestone = ProjectRelatedField(slug_field="name", required=False)
modified_date = serializers.DateTimeField(required=False)
diff --git a/taiga/projects/history/freeze_impl.py b/taiga/projects/history/freeze_impl.py
index a3524b69..706667ee 100644
--- a/taiga/projects/history/freeze_impl.py
+++ b/taiga/projects/history/freeze_impl.py
@@ -92,11 +92,14 @@ def _common_users_values(diff):
users.update(diff["owner"])
if "assigned_to" in diff:
users.update(diff["assigned_to"])
+ if "assigned_users" in diff:
+ [users.update(usrs_id) if usrs_id else None for usrs_id in diff["assigned_users"]]
if users:
values["users"] = _get_users_values(users)
return values
+
def project_values(diff):
values = _common_users_values(diff)
return values
@@ -344,7 +347,7 @@ def userstory_freezer(us) -> dict:
"subject": us.subject,
"description": us.description,
"description_html": mdrender(us.project, us.description),
- "assigned_to": us.assigned_to_id,
+ "assigned_users": [u.id for u in us.assigned_users.all()],
"milestone": us.milestone_id,
"client_requirement": us.client_requirement,
"team_requirement": us.team_requirement,
diff --git a/taiga/projects/history/models.py b/taiga/projects/history/models.py
index 88fd3c57..d24806f6 100644
--- a/taiga/projects/history/models.py
+++ b/taiga/projects/history/models.py
@@ -176,6 +176,15 @@ class HistoryEntry(models.Model):
(key, value) = resolve_diff_value(key)
elif key in users_keys:
value = [resolve_value("users", x) for x in self.diff[key]]
+ elif key == "assigned_users":
+ diff_in, diff_out = self.diff[key]
+ value_in = None
+ value_out = None
+ if diff_in:
+ value_in = ", ".join([resolve_value("users", x) for x in diff_in])
+ if diff_out:
+ value_out = ", ".join([resolve_value("users", x) for x in diff_out])
+ value = [value_in, value_out]
elif key == "points":
points = {}
diff --git a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja
index ddce4468..2e6138e7 100644
--- a/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja
+++ b/taiga/projects/history/templates/emails/includes/fields_diff-html.jinja
@@ -176,6 +176,33 @@
{% endif %}
+ {# ASSIGNED users #}
+ {% elif field_name == "assigned_users" %}
+
+
+ {{ verbose_name(obj_class, field_name) }}
+ |
+
+ {% if values.0 != None and values.0 != "" %}
+ {{ _("from") }}
+ {{ values.0 }}
+ {% else %}
+ {{ _("from") }}
+ {{ _("Unassigned") }}
+ {% endif %}
+ |
+
+
+
+ {% if values.1 != None and values.1 != "" %}
+ {{ _("to") }}
+ {{ values.1 }}
+ {% else %}
+ {{ _("to") }}
+ {{ _("Unassigned") }}
+ {% endif %}
+ |
+
{# * #}
{% else %}
diff --git a/taiga/projects/userstories/admin.py b/taiga/projects/userstories/admin.py
index f95b433a..818f2abe 100644
--- a/taiga/projects/userstories/admin.py
+++ b/taiga/projects/userstories/admin.py
@@ -67,6 +67,10 @@ class UserStoryAdmin(admin.ModelAdmin):
and getattr(self, 'obj', None)):
kwargs["queryset"] = db_field.related.parent_model.objects.filter(
memberships__project=self.obj.project)
+ elif (db_field.name in ["assigned_users"]
+ and getattr(self, 'obj', None)):
+ kwargs["queryset"] = db_field.related_model.objects.filter(
+ memberships__project=self.obj.project)
return super().formfield_for_manytomany(db_field, request, **kwargs)
diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py
index dc7cef82..fc12b81b 100644
--- a/taiga/projects/userstories/api.py
+++ b/taiga/projects/userstories/api.py
@@ -127,7 +127,6 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi
include_attachments=include_attachments,
include_tasks=include_tasks,
epic_id=epic_id)
-
return qs
def pre_conditions_on_save(self, obj):
diff --git a/taiga/projects/userstories/migrations/0015_userstory_assigned_users.py b/taiga/projects/userstories/migrations/0015_userstory_assigned_users.py
new file mode 100644
index 00000000..8d6462fd
--- /dev/null
+++ b/taiga/projects/userstories/migrations/0015_userstory_assigned_users.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.2 on 2018-02-13 10:14
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('userstories', '0014_auto_20160928_0540'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='userstory',
+ name='assigned_users',
+ field=models.ManyToManyField(blank=True, default=None, related_name='assigned_userstories', to=settings.AUTH_USER_MODEL, verbose_name='assigned users'),
+ ),
+ ]
diff --git a/taiga/projects/userstories/models.py b/taiga/projects/userstories/models.py
index bb802bf0..ed9add87 100644
--- a/taiga/projects/userstories/models.py
+++ b/taiga/projects/userstories/models.py
@@ -97,6 +97,9 @@ class UserStory(OCCModelMixin, WatchedModelMixin, BlockedMixin, TaggedMixin, Due
assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True,
default=None, related_name="userstories_assigned_to_me",
verbose_name=_("assigned to"))
+ assigned_users = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True,
+ default=None, related_name="assigned_userstories",
+ verbose_name=_("assigned users"))
client_requirement = models.BooleanField(default=False, null=False, blank=True,
verbose_name=_("is client requirement"))
team_requirement = models.BooleanField(default=False, null=False, blank=True,
diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py
index 02173c5a..ab6d5f47 100644
--- a/taiga/projects/userstories/serializers.py
+++ b/taiga/projects/userstories/serializers.py
@@ -83,6 +83,22 @@ class UserStoryListSerializer(ProjectExtraInfoSerializerMixin,
epic_order = MethodField()
tasks = MethodField()
+ assigned_users = MethodField()
+
+ # def get_assigned_users(self, obj):
+ # assert hasattr(obj, "assigned_users_attr"), "instance must have a assigned_users_attr attribute"
+ # if not obj.assigned_users_attr:
+ # return []
+ #
+ # return obj.assigned_users_attr
+
+ def get_assigned_users(self, obj):
+ """Get the assigned of an object.
+
+ :return: User queryset object representing the assigned users
+ """
+ return [user.id for user in obj.assigned_users.all()]
+
def get_epic_order(self, obj):
include_epic_order = getattr(obj, "include_epic_order", False)
diff --git a/taiga/projects/userstories/services.py b/taiga/projects/userstories/services.py
index 3af6b59a..db711d9a 100644
--- a/taiga/projects/userstories/services.py
+++ b/taiga/projects/userstories/services.py
@@ -38,6 +38,7 @@ from taiga.projects.notifications.utils import attach_watchers_to_queryset
from . import models
+
#####################################################
# Bulk actions
#####################################################
@@ -46,7 +47,8 @@ def get_userstories_from_bulk(bulk_data, **additional_fields):
"""Convert `bulk_data` into a list of user stories.
:param bulk_data: List of user stories in bulk format.
- :param additional_fields: Additional fields when instantiating each user story.
+ :param additional_fields: Additional fields when instantiating each user
+ story.
:return: List of `UserStory` instances.
"""
@@ -54,12 +56,14 @@ def get_userstories_from_bulk(bulk_data, **additional_fields):
for line in text.split_in_lines(bulk_data)]
-def create_userstories_in_bulk(bulk_data, callback=None, precall=None, **additional_fields):
+def create_userstories_in_bulk(bulk_data, callback=None, precall=None,
+ **additional_fields):
"""Create user stories from `bulk_data`.
: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.
+ :param additional_fields: Additional fields when instantiating each user
+ story.
:return: List of created `Task` instances.
"""
@@ -76,11 +80,13 @@ def create_userstories_in_bulk(bulk_data, callback=None, precall=None, **additio
return userstories
-def update_userstories_order_in_bulk(bulk_data: list, field: str, project: object,
- status: object=None, milestone: object=None):
+def update_userstories_order_in_bulk(bulk_data: list, field: str,
+ project: object,
+ status: object = None,
+ milestone: object = None):
"""
- Updates the order of the userstories specified adding the extra updates needed
- to keep consistency.
+ Updates the order of the userstories specified adding the extra updates
+ needed to keep consistency.
`bulk_data` should be a list of dicts with the following format:
`field` is the order field used
@@ -106,8 +112,8 @@ def update_userstories_order_in_bulk(bulk_data: list, field: str, project: objec
def update_userstories_milestone_in_bulk(bulk_data: list, milestone: object):
"""
- Update the milestone and the milestone order of some user stories adding the
- extra orders needed to keep consistency.
+ Update the milestone and the milestone order of some user stories adding
+ the extra orders needed to keep consistency.
`bulk_data` should be a list of dicts with the following format:
[{'us_id': , 'order': }, ...]
"""
@@ -116,7 +122,8 @@ def update_userstories_milestone_in_bulk(bulk_data: list, milestone: object):
new_us_orders = {}
for e in bulk_data:
new_us_orders[e["us_id"]] = e["order"]
- # The base orders where we apply the new orders must containg all the values
+ # The base orders where we apply the new orders must containg all
+ # the values
us_orders[e["us_id"]] = e["order"]
apply_order_updates(us_orders, new_us_orders)
@@ -128,11 +135,14 @@ def update_userstories_milestone_in_bulk(bulk_data: list, milestone: object):
content_type="userstories.userstory",
projectid=milestone.project.pk)
- db.update_attr_in_bulk_for_ids(us_milestones, "milestone_id", model=models.UserStory)
+ db.update_attr_in_bulk_for_ids(us_milestones, "milestone_id",
+ model=models.UserStory)
db.update_attr_in_bulk_for_ids(us_orders, "sprint_order", models.UserStory)
# Updating the milestone for the tasks
- Task.objects.filter(user_story_id__in=[e["us_id"] for e in bulk_data]).update(milestone=milestone)
+ Task.objects.filter(
+ user_story_id__in=[e["us_id"] for e in bulk_data]).update(
+ milestone=milestone)
return us_orders
@@ -157,7 +167,8 @@ def calculate_userstory_is_closed(user_story):
if user_story.tasks.count() == 0:
return user_story.status is not None and user_story.status.is_closed
- if all([task.status is not None and task.status.is_closed for task in user_story.tasks.all()]):
+ if all([task.status is not None and task.status.is_closed for task in
+ user_story.tasks.all()]):
return True
return False
@@ -183,9 +194,12 @@ def open_userstory(us):
def userstories_to_csv(project, queryset):
csv_data = io.StringIO()
- fieldnames = ["ref", "subject", "description", "sprint", "sprint_estimated_start",
- "sprint_estimated_finish", "owner", "owner_full_name", "assigned_to",
- "assigned_to_full_name", "status", "is_closed"]
+ fieldnames = ["ref", "subject", "description", "sprint",
+ "sprint_estimated_start",
+ "sprint_estimated_finish", "owner", "owner_full_name",
+ "assigned_to",
+ "assigned_to_full_name", "assigned_users",
+ "assigned_users_full_name", "status", "is_closed"]
roles = project.roles.filter(computable=True).order_by('slug')
for role in roles:
@@ -227,12 +241,21 @@ def userstories_to_csv(project, queryset):
"subject": us.subject,
"description": us.description,
"sprint": us.milestone.name if us.milestone else None,
- "sprint_estimated_start": us.milestone.estimated_start if us.milestone else None,
- "sprint_estimated_finish": us.milestone.estimated_finish if us.milestone else None,
+ "sprint_estimated_start": us.milestone.estimated_start if
+ us.milestone else None,
+ "sprint_estimated_finish": us.milestone.estimated_finish if
+ us.milestone else None,
"owner": us.owner.username if us.owner else None,
"owner_full_name": us.owner.get_full_name() if us.owner else None,
"assigned_to": us.assigned_to.username if us.assigned_to else None,
- "assigned_to_full_name": us.assigned_to.get_full_name() if us.assigned_to else None,
+ "assigned_to_full_name": us.assigned_to.get_full_name() if
+ us.assigned_to else None,
+ "assigned_users": ",".join(
+ [assigned_user.username for assigned_user in
+ us.assigned_users.all()]),
+ "assigned_users_full_name": ",".join(
+ [assigned_user.get_full_name() for assigned_user in
+ us.assigned_users.all()]),
"status": us.status.name if us.status else None,
"is_closed": us.is_closed,
"backlog_order": us.backlog_order,
@@ -244,7 +267,8 @@ def userstories_to_csv(project, queryset):
"client_requirement": us.client_requirement,
"team_requirement": us.team_requirement,
"attachments": us.attachments.count(),
- "generated_from_issue": us.generated_from_issue.ref if us.generated_from_issue else None,
+ "generated_from_issue": us.generated_from_issue.ref if
+ us.generated_from_issue else None,
"external_reference": us.external_reference,
"tasks": ",".join([str(task.ref) for task in us.tasks.all()]),
"tags": ",".join(us.tags or []),
@@ -254,14 +278,17 @@ def userstories_to_csv(project, queryset):
"due_date_reason": us.due_date_reason,
}
- us_role_points_by_role_id = {us_rp.role.id: us_rp.points.value for us_rp in us.role_points.all()}
+ us_role_points_by_role_id = {us_rp.role.id: us_rp.points.value for
+ us_rp in us.role_points.all()}
for role in roles:
- row["{}-points".format(role.slug)] = us_role_points_by_role_id.get(role.id, 0)
+ row["{}-points".format(role.slug)] = \
+ us_role_points_by_role_id.get(role.id, 0)
row['total-points'] = us.get_total_points()
for custom_attr in custom_attrs:
- value = us.custom_attributes_values.attributes_values.get(str(custom_attr.id), None)
+ value = us.custom_attributes_values.attributes_values.get(
+ str(custom_attr.id), None)
row[custom_attr.name] = value
writer.writerow(row)
diff --git a/taiga/projects/userstories/utils.py b/taiga/projects/userstories/utils.py
index 3a903491..952f4ca6 100644
--- a/taiga/projects/userstories/utils.py
+++ b/taiga/projects/userstories/utils.py
@@ -157,6 +157,7 @@ def attach_extra_info(queryset, user=None, include_attachments=False, include_ta
queryset = attach_total_points(queryset)
queryset = attach_role_points(queryset)
queryset = attach_epics(queryset)
+ # queryset = attach_assigned_users(queryset)
if include_attachments:
queryset = attach_basic_attachments(queryset)
@@ -177,3 +178,22 @@ def attach_extra_info(queryset, user=None, include_attachments=False, include_ta
queryset = attach_is_watcher_to_queryset(queryset, user)
queryset = attach_total_comments_to_queryset(queryset)
return queryset
+
+
+def attach_assigned_users(queryset, as_field="assigned_users_attr"):
+ """Attach assigned users as json column to each object of the queryset.
+
+ :param queryset: A Django user stories queryset object.
+ :param as_field: Attach assigned as an attribute with this name.
+
+ :return: Queryset object with the additional `as_field` field.
+ """
+
+ model = queryset.model
+ sql = """SELECT "userstories_userstory_assigned_users"."user_id" AS "user_id"
+ FROM "userstories_userstory_assigned_users"
+ WHERE "userstories_userstory_assigned_users"."userstory_id" = {tbl}.id"""
+
+ sql = sql.format(tbl=model._meta.db_table)
+ queryset = queryset.extra(select={as_field: sql})
+ return queryset
\ No newline at end of file
diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py
index a8f4054b..ef0559c6 100644
--- a/tests/integration/test_userstories.py
+++ b/tests/integration/test_userstories.py
@@ -69,6 +69,44 @@ def test_update_userstories_order_in_bulk():
models.UserStory)
+def test_create_userstory_with_assign_to(client):
+ user = f.UserFactory.create()
+ user_watcher = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user)
+ f.MembershipFactory.create(project=project, user=user, is_admin=True)
+ f.MembershipFactory.create(project=project, user=user_watcher,
+ is_admin=True)
+ url = reverse("userstories-list")
+
+ data = {"subject": "Test user story", "project": project.id,
+ "assigned_to": user.id}
+ client.login(user)
+ response = client.json.post(url, json.dumps(data))
+
+ assert response.status_code == 201
+ assert response.data["assigned_to"] == user.id
+
+
+def test_create_userstory_with_assigned_users(client):
+ user = f.UserFactory.create()
+ user_watcher = f.UserFactory.create()
+ project = f.ProjectFactory.create(owner=user)
+ f.MembershipFactory.create(project=project, user=user, is_admin=True)
+ f.MembershipFactory.create(project=project, user=user_watcher,
+ is_admin=True)
+ url = reverse("userstories-list")
+
+ data = {"subject": "Test user story", "project": project.id,
+ "assigned_users": [user.id, user_watcher.id]}
+ client.login(user)
+ json_data = json.dumps(data)
+
+ response = client.json.post(url, json_data)
+
+ assert response.status_code == 201
+ assert response.data["assigned_users"] == [user.id, user_watcher.id]
+
+
def test_create_userstory_with_watchers(client):
user = f.UserFactory.create()
user_watcher = f.UserFactory.create()