From 94fcbbc55e8f323240ce081b88d33cd16db2379e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Hermida?= Date: Tue, 13 Feb 2018 11:53:28 +0100 Subject: [PATCH] Add assigned users to us Also: * csv export * json export * import validator * history * Email template --- .../export_import/serializers/serializers.py | 5 ++ taiga/export_import/validators/fields.py | 1 + taiga/export_import/validators/validators.py | 1 + taiga/projects/history/freeze_impl.py | 5 +- taiga/projects/history/models.py | 9 +++ .../emails/includes/fields_diff-html.jinja | 27 +++++++ taiga/projects/userstories/admin.py | 4 + taiga/projects/userstories/api.py | 1 - .../0015_userstory_assigned_users.py | 22 ++++++ taiga/projects/userstories/models.py | 3 + taiga/projects/userstories/serializers.py | 16 ++++ taiga/projects/userstories/services.py | 73 +++++++++++++------ taiga/projects/userstories/utils.py | 20 +++++ tests/integration/test_userstories.py | 38 ++++++++++ 14 files changed, 200 insertions(+), 25 deletions(-) create mode 100644 taiga/projects/userstories/migrations/0015_userstory_assigned_users.py 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()