diff --git a/taiga/base/filters.py b/taiga/base/filters.py index 1cd19e64..e26e7911 100644 --- a/taiga/base/filters.py +++ b/taiga/base/filters.py @@ -152,7 +152,7 @@ class PermissionBasedFilterBackend(FilterBackend): else: qs = qs.filter(project__anon_permissions__contains=[self.permission]) - return super().filter_queryset(request, qs.distinct(), view) + return super().filter_queryset(request, qs, view) class CanViewProjectFilterBackend(PermissionBasedFilterBackend): @@ -268,7 +268,7 @@ class MembersFilterBackend(PermissionBasedFilterBackend): qs = qs.filter(memberships__project__anon_permissions__contains=[self.permission]) - return qs.distinct() + return qs ##################################################################### @@ -307,7 +307,7 @@ class IsProjectAdminFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend else: queryset = queryset.filter(project_id__in=project_ids) - return super().filter_queryset(request, queryset.distinct(), view) + return super().filter_queryset(request, queryset, view) class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend): diff --git a/taiga/projects/attachments/migrations/0006_auto_20160617_1233.py b/taiga/projects/attachments/migrations/0006_auto_20160617_1233.py new file mode 100644 index 00000000..ee291a9f --- /dev/null +++ b/taiga/projects/attachments/migrations/0006_auto_20160617_1233.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-06-17 12:33 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('attachments', '0005_attachment_sha1'), + ] + + operations = [ + migrations.AlterIndexTogether( + name='attachment', + index_together=set([('content_type', 'object_id')]), + ), + ] diff --git a/taiga/projects/attachments/models.py b/taiga/projects/attachments/models.py index 8bbbee16..a5110a4b 100644 --- a/taiga/projects/attachments/models.py +++ b/taiga/projects/attachments/models.py @@ -70,6 +70,7 @@ class Attachment(models.Model): permissions = ( ("view_attachment", "Can view attachment"), ) + index_together = [("content_type", "object_id")] def __init__(self, *args, **kwargs): super(Attachment, self).__init__(*args, **kwargs) diff --git a/taiga/projects/attachments/serializers.py b/taiga/projects/attachments/serializers.py index 904498a9..6c5ee05b 100644 --- a/taiga/projects/attachments/serializers.py +++ b/taiga/projects/attachments/serializers.py @@ -16,11 +16,17 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from django.conf import settings + from taiga.base.api import serializers +from taiga.base.utils.thumbnails import get_thumbnail_url from . import services from . import models +import json +import serpy + class AttachmentSerializer(serializers.ModelSerializer): url = serializers.SerializerMethodField("get_url") @@ -37,5 +43,31 @@ class AttachmentSerializer(serializers.ModelSerializer): def get_url(self, obj): return obj.attached_file.url + def get_thumbnail_card_url(self, obj): return services.get_card_image_thumbnail_url(obj) + + +class ListBasicAttachmentsInfoSerializerMixin(serpy.Serializer): + """ + Assumptions: + - The queryset has an attribute called "include_attachments" indicating if the attachments array should contain information + about the related elements, otherwise it will be empty + - The method attach_basic_attachments has been used to include the necessary + json data about the attachments in the "attachments_attr" column + """ + attachments = serpy.MethodField() + + def get_attachments(self, obj): + include_attachments = getattr(obj, "include_attachments", False) + + if include_attachments: + assert hasattr(obj, "attachments_attr"), "instance must have a attachments_attr attribute" + + if not include_attachments or obj.attachments_attr is None: + return [] + + for at in obj.attachments_attr: + at["thumbnail_card_url"] = get_thumbnail_url(at["attached_file"], settings.THN_ATTACHMENT_CARD) + + return obj.attachments_attr diff --git a/taiga/projects/attachments/utils.py b/taiga/projects/attachments/utils.py new file mode 100644 index 00000000..33e36c44 --- /dev/null +++ b/taiga/projects/attachments/utils.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014-2016 Andrey Antukh +# Copyright (C) 2014-2016 Jesús Espino +# Copyright (C) 2014-2016 David Barragán +# Copyright (C) 2014-2016 Alejandro Alonso +# Copyright (C) 2014-2016 Anler Hernández +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.apps import apps + +def attach_basic_attachments(queryset, as_field="attachments_attr"): + """Attach basic attachments info as json column to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param as_field: Attach the role points as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + + model = queryset.model + type = apps.get_model("contenttypes", "ContentType").objects.get_for_model(model) + + sql = """SELECT json_agg(row_to_json(t)) + FROM( + SELECT + attachments_attachment.id, + attachments_attachment.attached_file + FROM attachments_attachment + WHERE attachments_attachment.object_id = {tbl}.id AND attachments_attachment.content_type_id = {type_id}) t""" + + sql = sql.format(tbl=model._meta.db_table, type_id=type.id) + queryset = queryset.extra(select={as_field: sql}) + return queryset diff --git a/taiga/projects/issues/serializers.py b/taiga/projects/issues/serializers.py index 4243ea31..099171a1 100644 --- a/taiga/projects/issues/serializers.py +++ b/taiga/projects/issues/serializers.py @@ -21,9 +21,9 @@ from taiga.base.fields import PgArrayField from taiga.base.neighbors import NeighborsSerializerMixin from taiga.mdrender.service import render as mdrender -from taiga.projects.mixins.serializers import OwnerExtraInfoMixin -from taiga.projects.mixins.serializers import AssigedToExtraInfoMixin -from taiga.projects.mixins.serializers import StatusExtraInfoMixin +from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer from taiga.projects.notifications.validators import WatchersValidator @@ -39,6 +39,7 @@ from . import models import serpy + class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer, serializers.ModelSerializer): tags = TagsAndTagsColorsField(default=[], required=False) @@ -75,8 +76,8 @@ class IssueSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWa class IssueListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer, - OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin, - serializers.LightSerializer): + ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin, + ListStatusExtraInfoSerializerMixin, serializers.LightSerializer): id = serpy.Field() ref = serpy.Field() severity = serpy.Field(attr="severity_id") diff --git a/taiga/projects/milestones/serializers.py b/taiga/projects/milestones/serializers.py index b3df7c8f..724126fd 100644 --- a/taiga/projects/milestones/serializers.py +++ b/taiga/projects/milestones/serializers.py @@ -35,6 +35,7 @@ class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer, ValidateDuplicatedNameInProjectMixin): total_points = serializers.SerializerMethodField("get_total_points") closed_points = serializers.SerializerMethodField("get_closed_points") + user_stories = serializers.SerializerMethodField("get_user_stories") class Meta: model = models.Milestone @@ -46,6 +47,9 @@ class MilestoneSerializer(WatchersValidator, WatchedResourceModelSerializer, def get_closed_points(self, obj): return sum(obj.closed_points.values()) + def get_user_stories(self, obj): + return UserStoryListSerializer(obj.user_stories.all(), many=True).data + class MilestoneListSerializer(ListWatchedResourceModelSerializer, serializers.LightSerializer): id = serpy.Field() @@ -62,8 +66,16 @@ class MilestoneListSerializer(ListWatchedResourceModelSerializer, serializers.Li order = serpy.Field() watchers = serpy.Field() user_stories = serpy.MethodField("get_user_stories") - total_points = serializers.Field(source="total_points_attr") - closed_points = serializers.Field(source="closed_points_attr") + total_points = serpy.MethodField() + closed_points = serpy.MethodField() def get_user_stories(self, obj): return UserStoryListSerializer(obj.user_stories.all(), many=True).data + + def get_total_points(self, obj): + assert hasattr(obj, "total_points_attr"), "instance must have a total_points_attr attribute" + return obj.total_points_attr + + def get_closed_points(self, obj): + assert hasattr(obj, "closed_points_attr"), "instance must have a closed_points_attr attribute" + return obj.closed_points_attr diff --git a/taiga/projects/mixins/serializers.py b/taiga/projects/mixins/serializers.py index 2d788298..a47d9bed 100644 --- a/taiga/projects/mixins/serializers.py +++ b/taiga/projects/mixins/serializers.py @@ -44,7 +44,7 @@ class ValidateDuplicatedNameInProjectMixin(serializers.ModelSerializer): return attrs -class CachedSerializedUsersMixin(serpy.Serializer): +class ListCachedUsersSerializerMixin(serpy.Serializer): def to_value(self, instance): self._serialized_users = {} return super().to_value(instance) @@ -61,7 +61,7 @@ class CachedSerializedUsersMixin(serpy.Serializer): return serialized_user -class OwnerExtraInfoMixin(CachedSerializedUsersMixin): +class ListOwnerExtraInfoSerializerMixin(ListCachedUsersSerializerMixin): owner = serpy.Field(attr="owner_id") owner_extra_info = serpy.MethodField() @@ -69,7 +69,7 @@ class OwnerExtraInfoMixin(CachedSerializedUsersMixin): return self.get_user_extra_info(obj.owner) -class AssigedToExtraInfoMixin(CachedSerializedUsersMixin): +class ListAssignedToExtraInfoSerializerMixin(ListCachedUsersSerializerMixin): assigned_to = serpy.Field(attr="assigned_to_id") assigned_to_extra_info = serpy.MethodField() @@ -77,9 +77,10 @@ class AssigedToExtraInfoMixin(CachedSerializedUsersMixin): return self.get_user_extra_info(obj.assigned_to) -class StatusExtraInfoMixin(serpy.Serializer): +class ListStatusExtraInfoSerializerMixin(serpy.Serializer): status = serpy.Field(attr="status_id") status_extra_info = serpy.MethodField() + def to_value(self, instance): self._serialized_status = {} return super().to_value(instance) diff --git a/taiga/projects/tasks/api.py b/taiga/projects/tasks/api.py index b7c13ab1..d7723a26 100644 --- a/taiga/projects/tasks/api.py +++ b/taiga/projects/tasks/api.py @@ -25,6 +25,8 @@ from taiga.base import exceptions as exc from taiga.base.decorators import list_route from taiga.base.api import ModelCrudViewSet, ModelListViewSet from taiga.base.api.mixins import BlockedByProjectMixin + +from taiga.projects.attachments.utils import attach_basic_attachments from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.models import Project, TaskStatus from taiga.projects.notifications.mixins import WatchedResourceMixin, WatchersViewSetMixin @@ -94,13 +96,19 @@ class TaskViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixin, Wa def get_queryset(self): qs = super().get_queryset() qs = self.attach_votes_attrs_to_queryset(qs) - qs = qs.select_related("milestone", - "owner", - "assigned_to", - "status", - "project") + qs = qs.select_related( + "milestone", + "owner", + "assigned_to", + "status", + "project") - return self.attach_watchers_attrs_to_queryset(qs) + qs = self.attach_watchers_attrs_to_queryset(qs) + if "include_attachments" in self.request.QUERY_PARAMS: + qs = attach_basic_attachments(qs) + qs = qs.extra(select={"include_attachments": "True"}) + + return qs def pre_save(self, obj): if obj.user_story: diff --git a/taiga/projects/tasks/serializers.py b/taiga/projects/tasks/serializers.py index d7423e66..ac82c570 100644 --- a/taiga/projects/tasks/serializers.py +++ b/taiga/projects/tasks/serializers.py @@ -23,10 +23,12 @@ from taiga.base.api import serializers from taiga.base.fields import PgArrayField from taiga.base.neighbors import NeighborsSerializerMixin +from taiga.mdrender.service import render as mdrender +from taiga.projects.attachments.serializers import ListBasicAttachmentsInfoSerializerMixin from taiga.projects.milestones.validators import SprintExistsValidator -from taiga.projects.mixins.serializers import OwnerExtraInfoMixin -from taiga.projects.mixins.serializers import AssigedToExtraInfoMixin -from taiga.projects.mixins.serializers import StatusExtraInfoMixin +from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer from taiga.projects.notifications.validators import WatchersValidator @@ -46,8 +48,10 @@ from . import models import serpy + class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWatchedResourceModelSerializer, serializers.ModelSerializer): + tags = TagsAndTagsColorsField(default=[], required=False) external_reference = PgArrayField(required=False) comment = serializers.SerializerMethodField("get_comment") @@ -83,8 +87,10 @@ class TaskSerializer(WatchersValidator, VoteResourceSerializerMixin, EditableWat class TaskListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer, - OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin, + ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin, + ListStatusExtraInfoSerializerMixin, ListBasicAttachmentsInfoSerializerMixin, serializers.LightSerializer): + id = serpy.Field() user_story = serpy.Field(attr="user_story_id") ref = serpy.Field() diff --git a/taiga/projects/userstories/api.py b/taiga/projects/userstories/api.py index dbe5c433..0c54f81d 100644 --- a/taiga/projects/userstories/api.py +++ b/taiga/projects/userstories/api.py @@ -36,6 +36,7 @@ from taiga.base.api import ModelCrudViewSet from taiga.base.api import ModelListViewSet from taiga.base.api.utils import get_object_or_404 +from taiga.projects.attachments.utils import attach_basic_attachments from taiga.projects.history.mixins import HistoryResourceMixin from taiga.projects.history.services import take_snapshot from taiga.projects.milestones.models import Milestone @@ -49,6 +50,7 @@ from taiga.projects.votes.mixins.viewsets import VotedResourceMixin from taiga.projects.votes.mixins.viewsets import VotersViewSetMixin from taiga.projects.userstories.utils import attach_total_points from taiga.projects.userstories.utils import attach_role_points +from taiga.projects.userstories.utils import attach_tasks from . import models from . import permissions @@ -105,10 +107,20 @@ class UserStoryViewSet(OCCResourceMixin, VotedResourceMixin, HistoryResourceMixi "owner", "assigned_to", "generated_from_issue") + qs = self.attach_votes_attrs_to_queryset(qs) qs = self.attach_watchers_attrs_to_queryset(qs) qs = attach_total_points(qs) qs = attach_role_points(qs) + + if "include_attachments" in self.request.QUERY_PARAMS: + qs = attach_basic_attachments(qs) + qs = qs.extra(select={"include_attachments": "True"}) + + if "include_tasks" in self.request.QUERY_PARAMS: + qs = attach_tasks(qs) + qs = qs.extra(select={"include_tasks": "True"}) + return qs def pre_conditions_on_save(self, obj): diff --git a/taiga/projects/userstories/serializers.py b/taiga/projects/userstories/serializers.py index c863c87b..236a54d8 100644 --- a/taiga/projects/userstories/serializers.py +++ b/taiga/projects/userstories/serializers.py @@ -29,20 +29,19 @@ from taiga.base.neighbors import NeighborsSerializerMixin from taiga.base.utils import json from taiga.mdrender.service import render as mdrender - +from taiga.projects.attachments.serializers import ListBasicAttachmentsInfoSerializerMixin from taiga.projects.milestones.validators import SprintExistsValidator +from taiga.projects.mixins.serializers import ListOwnerExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import ListAssignedToExtraInfoSerializerMixin +from taiga.projects.mixins.serializers import ListStatusExtraInfoSerializerMixin from taiga.projects.models import Project, UserStoryStatus -from taiga.projects.mixins.serializers import OwnerExtraInfoMixin -from taiga.projects.mixins.serializers import AssigedToExtraInfoMixin -from taiga.projects.mixins.serializers import StatusExtraInfoMixin from taiga.projects.notifications.mixins import EditableWatchedResourceModelSerializer from taiga.projects.notifications.mixins import ListWatchedResourceModelSerializer from taiga.projects.notifications.validators import WatchersValidator from taiga.projects.serializers import BasicUserStoryStatusSerializer from taiga.projects.tagging.fields import TagsAndTagsColorsField from taiga.projects.userstories.validators import UserStoryExistsValidator -from taiga.projects.validators import ProjectExistsValidator -from taiga.projects.validators import UserStoryStatusExistsValidator +from taiga.projects.validators import ProjectExistsValidator, UserStoryStatusExistsValidator from taiga.projects.votes.mixins.serializers import VoteResourceSerializerMixin from taiga.projects.votes.mixins.serializers import ListVoteResourceSerializerMixin @@ -136,7 +135,9 @@ class ListOriginIssueSerializer(serializers.LightSerializer): class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResourceModelSerializer, - OwnerExtraInfoMixin, AssigedToExtraInfoMixin, StatusExtraInfoMixin, serializers.LightSerializer): + ListOwnerExtraInfoSerializerMixin, ListAssignedToExtraInfoSerializerMixin, + ListStatusExtraInfoSerializerMixin, ListBasicAttachmentsInfoSerializerMixin, + serializers.LightSerializer): id = serpy.Field() ref = serpy.Field() @@ -163,13 +164,11 @@ class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResour is_blocked = serpy.Field() blocked_note = serpy.Field() tags = serpy.Field() - total_points = serpy.Field("total_points_attr") + total_points = serpy.MethodField() comment = serpy.MethodField("get_comment") origin_issue = ListOriginIssueSerializer(attr="generated_from_issue") - def to_value(self, instance): - self._serialized_status = {} - return super().to_value(instance) + tasks = serpy.MethodField() def get_milestone_slug(self, obj): return obj.milestone.slug if obj.milestone else None @@ -177,15 +176,31 @@ class UserStoryListSerializer(ListVoteResourceSerializerMixin, ListWatchedResour def get_milestone_name(self, obj): return obj.milestone.name if obj.milestone else None + def get_total_points(self, obj): + assert hasattr(obj, "total_points_attr"), "instance must have a total_points_attr attribute" + return obj.total_points_attr + def get_points(self, obj): + assert hasattr(obj, "role_points_attr"), "instance must have a role_points_attr attribute" if obj.role_points_attr is None: return {} - return dict(ChainMap(*json.loads(obj.role_points_attr))) + return dict(ChainMap(*obj.role_points_attr)) def get_comment(self, obj): return "" + def get_tasks(self, obj): + include_tasks = getattr(obj, "include_tasks", False) + + if include_tasks: + assert hasattr(obj, "tasks_attr"), "instance must have a tasks_attr attribute" + + if not include_tasks or obj.tasks_attr is None: + return [] + + return obj.tasks_attr + class UserStoryNeighborsSerializer(NeighborsSerializerMixin, UserStorySerializer): def serialize_neighbor(self, neighbor): diff --git a/taiga/projects/userstories/utils.py b/taiga/projects/userstories/utils.py index 36a9970d..809248f7 100644 --- a/taiga/projects/userstories/utils.py +++ b/taiga/projects/userstories/utils.py @@ -46,11 +46,39 @@ def attach_role_points(queryset, as_field="role_points_attr"): :return: Queryset object with the additional `as_field` field. """ model = queryset.model - sql = """SELECT json_agg(json_build_object(userstories_rolepoints.role_id, - userstories_rolepoints.points_id))::text + sql = """SELECT json_agg((userstories_rolepoints.role_id, userstories_rolepoints.points_id)) FROM userstories_rolepoints WHERE userstories_rolepoints.user_story_id = {tbl}.id""" sql = sql.format(tbl=model._meta.db_table) queryset = queryset.extra(select={as_field: sql}) return queryset + + +def attach_tasks(queryset, as_field="tasks_attr"): + """Attach tasks as json column to each object of the queryset. + + :param queryset: A Django user stories queryset object. + :param as_field: Attach the role points as an attribute with this name. + + :return: Queryset object with the additional `as_field` field. + """ + + model = queryset.model + sql = """SELECT json_agg(row_to_json(t)) + FROM( + SELECT + tasks_task.id, + tasks_task.ref, + tasks_task.subject, + tasks_task.status_id, + tasks_task.is_blocked, + tasks_task.is_iocaine, + projects_taskstatus.is_closed + FROM tasks_task + INNER JOIN projects_taskstatus on projects_taskstatus.id = tasks_task.status_id + WHERE user_story_id = {tbl}.id) t""" + + sql = sql.format(tbl=model._meta.db_table) + queryset = queryset.extra(select={as_field: sql}) + return queryset diff --git a/tests/integration/test_tasks.py b/tests/integration/test_tasks.py index fcecee3b..072c0595 100644 --- a/tests/integration/test_tasks.py +++ b/tests/integration/test_tasks.py @@ -185,3 +185,24 @@ def test_custom_fields_csv_generation(): assert row[24] == attr.name row = next(reader) assert row[24] == "val1" + + +def test_get_tasks_including_attachments(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + task = f.TaskFactory.create(project=project) + f.TaskAttachmentFactory(project=project, content_object=task) + url = reverse("tasks-list") + + client.login(project.owner) + + response = client.get(url) + assert response.status_code == 200 + assert response.data[0].get("attachments") == [] + + url = reverse("tasks-list") + "?include_attachments=1" + response = client.get(url) + assert response.status_code == 200 + assert len(response.data[0].get("attachments")) == 1 diff --git a/tests/integration/test_userstories.py b/tests/integration/test_userstories.py index bc3c5560..e05fff68 100644 --- a/tests/integration/test_userstories.py +++ b/tests/integration/test_userstories.py @@ -644,3 +644,45 @@ def test_update_userstory_update_tribe_gig(client): assert response.status_code == 200 assert response.data["tribe_gig"] == data["tribe_gig"] + + +def test_get_user_stories_including_tasks(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + user_story = f.UserStoryFactory.create(project=project) + f.TaskFactory.create(user_story=user_story) + url = reverse("userstories-list") + + client.login(project.owner) + + response = client.get(url) + assert response.status_code == 200 + assert response.data[0].get("tasks") == [] + + url = reverse("userstories-list") + "?include_tasks=1" + response = client.get(url) + assert response.status_code == 200 + assert len(response.data[0].get("tasks")) == 1 + + +def test_get_user_stories_including_attachments(client): + user = f.UserFactory.create() + project = f.ProjectFactory.create(owner=user) + f.MembershipFactory.create(project=project, user=user, is_admin=True) + + user_story = f.UserStoryFactory.create(project=project) + f.UserStoryAttachmentFactory(project=project, content_object=user_story) + url = reverse("userstories-list") + + client.login(project.owner) + + response = client.get(url) + assert response.status_code == 200 + assert response.data[0].get("attachments") == [] + + url = reverse("userstories-list") + "?include_attachments=1" + response = client.get(url) + assert response.status_code == 200 + assert len(response.data[0].get("attachments")) == 1