Add assigned users to us

Also:
 * csv export
 * json export
 * import validator
 * history
 * Email template
remotes/origin/3.4.0rc
Álex Hermida 2018-02-13 11:53:28 +01:00
parent 2284ec6b6e
commit 94fcbbc55e
14 changed files with 200 additions and 25 deletions

View File

@ -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()

View File

@ -159,6 +159,7 @@ class HistorySnapshotField(JSONField):
return data
class HistoryUserField(JSONField):
def from_native(self, data):
if data is None:

View File

@ -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)

View File

@ -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,

View File

@ -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 = {}

View File

@ -176,6 +176,33 @@
{% endif %}
</td>
</tr>
{# ASSIGNED users #}
{% elif field_name == "assigned_users" %}
<tr>
<td valign="middle" rowspan="2" class="update-row-name">
<h3>{{ verbose_name(obj_class, field_name) }}</h3>
</td>
<td valign="top" class="update-row-from">
{% if values.0 != None and values.0 != "" %}
<span>{{ _("from") }}</span><br>
<strong>{{ values.0 }}</strong>
{% else %}
<span>{{ _("from") }}</span><br>
<strong>{{ _("Unassigned") }}</strong>
{% endif %}
</td>
</tr>
<tr>
<td valign="top">
{% if values.1 != None and values.1 != "" %}
<span>{{ _("to") }}</span><br>
<strong>{{ values.1 }}</strong>
{% else %}
<span>{{ _("to") }}</span><br>
<strong>{{ _("Unassigned") }}</strong>
{% endif %}
</td>
</tr>
{# * #}
{% else %}
<tr>

View File

@ -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)

View File

@ -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):

View File

@ -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'),
),
]

View File

@ -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,

View File

@ -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)

View File

@ -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': <value>, 'order': <value>}, ...]
"""
@ -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)

View File

@ -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

View File

@ -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()