US#1913: CSV Reports

remotes/origin/enhancement/email-actions
Jesús Espino 2015-02-19 16:51:36 +01:00 committed by David Barragán Merino
parent d984557449
commit 66e6512245
21 changed files with 443 additions and 4 deletions

View File

@ -5,6 +5,7 @@
### Features
- Added custom fields per project for user stories, tasks and issues.
- Support of export to CSV user stories, tasks and issues.
- Allow public projects.
### Misc

View File

@ -87,6 +87,33 @@ class ProjectViewSet(ModelCrudViewSet):
self.check_permissions(request, "stats", project)
return response.Ok(services.get_stats_for_project(project))
def _regenerate_csv_uuid(self, project, field):
uuid_value = uuid.uuid4().hex
setattr(project, field, uuid_value)
project.save()
return uuid_value
@detail_route(methods=["POST"])
def regenerate_userstories_csv_uuid(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "regenerate_userstories_csv_uuid", project)
data = {"uuid": self._regenerate_csv_uuid(project, "userstories_csv_uuid")}
return response.Ok(data)
@detail_route(methods=["POST"])
def regenerate_issues_csv_uuid(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "regenerate_issues_csv_uuid", project)
data = {"uuid": self._regenerate_csv_uuid(project, "issues_csv_uuid")}
return response.Ok(data)
@detail_route(methods=["POST"])
def regenerate_tasks_csv_uuid(self, request, pk=None):
project = self.get_object()
self.check_permissions(request, "regenerate_tasks_csv_uuid", project)
data = {"uuid": self._regenerate_csv_uuid(project, "tasks_csv_uuid")}
return response.Ok(data)
@detail_route(methods=["GET"])
def member_stats(self, request, pk=None):
project = self.get_object()

View File

@ -16,7 +16,7 @@
from django.utils.translation import ugettext_lazy as _
from django.db.models import Q
from django.http import Http404
from django.http import Http404, HttpResponse
from taiga.base import filters
from taiga.base import exceptions as exc
@ -24,7 +24,6 @@ from taiga.base import response
from taiga.base.decorators import detail_route, list_route
from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.api.utils import get_object_or_404
from taiga.base import tags
from taiga.users.models import User
@ -163,6 +162,19 @@ class IssueViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
issue = get_object_or_404(models.Issue, ref=ref, project_id=project_id)
return self.retrieve(request, pk=issue.pk)
@list_route(methods=["GET"])
def csv(self, request):
uuid = request.QUERY_PARAMS.get("uuid", None)
if uuid is None:
return response.NotFound()
project = get_object_or_404(Project, issues_csv_uuid=uuid)
queryset = project.issues.all().order_by('ref')
data = services.issues_to_csv(queryset)
csv_response = HttpResponse(data.getvalue(), content_type='application/csv')
csv_response['Content-Disposition'] = 'attachment; filename="issues.csv"'
return csv_response
@list_route(methods=["POST"])
def bulk_create(self, request, **kwargs):
serializer = serializers.IssuesBulkSerializer(data=request.DATA)

View File

@ -28,6 +28,7 @@ class IssuePermission(TaigaResourcePermission):
update_perms = HasProjectPerm('modify_issue')
destroy_perms = HasProjectPerm('delete_issue')
list_perms = AllowAny()
csv_perms = AllowAny()
upvote_perms = IsAuthenticated() & HasProjectPerm('vote_issues')
downvote_perms = IsAuthenticated() & HasProjectPerm('vote_issues')
bulk_create_perms = HasProjectPerm('add_issue')

View File

@ -14,6 +14,9 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import io
import csv
from taiga.base.utils import db, text
from . import models
@ -58,3 +61,33 @@ def update_issues_order_in_bulk(bulk_data):
issue_ids.append(issue_id)
new_order_values.append({"order": new_order_value})
db.update_in_bulk_with_ids(issue_ids, new_order_values, model=models.Issue)
def issues_to_csv(queryset):
csv_data = io.StringIO()
fieldnames = ["ref", "subject", "description", "milestone", "owner",
"owner_full_name", "assigned_to", "assigned_to_full_name",
"status", "severity", "priority", "type", "is_closed",
"attachments", "external_reference"]
writer = csv.DictWriter(csv_data, fieldnames=fieldnames)
writer.writeheader()
for issue in queryset:
writer.writerow({
"ref": issue.ref,
"subject": issue.subject,
"description": issue.description,
"milestone": issue.milestone.name if issue.milestone else None,
"owner": issue.owner.username,
"owner_full_name": issue.owner.get_full_name(),
"assigned_to": issue.assigned_to.username if issue.assigned_to else None,
"assigned_to_full_name": issue.assigned_to.get_full_name() if issue.assigned_to else None,
"status": issue.status.name,
"severity": issue.severity.name,
"priority": issue.priority.name,
"type": issue.type.name,
"is_closed": issue.is_closed,
"attachments": issue.attachments.count(),
"external_reference": issue.external_reference,
})
return csv_data

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('projects', '0016_fix_json_field_not_null'),
]
operations = [
migrations.AddField(
model_name='project',
name='issues_csv_uuid',
field=models.CharField(editable=False, max_length=32, default=None, null=True, db_index=True, blank=True),
preserve_default=True,
),
migrations.AddField(
model_name='project',
name='tasks_csv_uuid',
field=models.CharField(editable=False, max_length=32, default=None, null=True, db_index=True, blank=True),
preserve_default=True,
),
migrations.AddField(
model_name='project',
name='userstories_csv_uuid',
field=models.CharField(editable=False, max_length=32, default=None, null=True, db_index=True, blank=True),
preserve_default=True,
),
]

View File

@ -15,6 +15,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import itertools
import uuid
from django.core.exceptions import ValidationError
from django.db import models
@ -163,6 +165,15 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
is_private = models.BooleanField(default=True, null=False, blank=True,
verbose_name=_("is private"))
userstories_csv_uuid = models.CharField(max_length=32, editable=False,
null=True, blank=True,
default=None, db_index=True)
tasks_csv_uuid = models.CharField(max_length=32, editable=False, null=True,
blank=True, default=None, db_index=True)
issues_csv_uuid = models.CharField(max_length=32, editable=False,
null=True, blank=True, default=None,
db_index=True)
tags_colors = TextArrayField(dimension=2, null=False, blank=True, verbose_name=_("tags colors"), default=[])
_importing = None
@ -181,6 +192,15 @@ class Project(ProjectDefaults, TaggedMixin, models.Model):
return "<Project {0}>".format(self.id)
def save(self, *args, **kwargs):
if not self._importing and not self.userstories_csv_uuid:
self.userstories_csv_uuid = uuid.uuid4().hex
if not self._importing and not self.tasks_csv_uuid:
self.tasks_csv_uuid = uuid.uuid4().hex
if not self._importing and not self.issues_csv_uuid:
self.issues_csv_uuid = uuid.uuid4().hex
if not self._importing or not self.modified_date:
self.modified_date = timezone.now()

View File

@ -54,6 +54,9 @@ class ProjectPermission(TaigaResourcePermission):
list_perms = AllowAny()
stats_perms = HasProjectPerm('view_project')
member_stats_perms = HasProjectPerm('view_project')
regenerate_userstories_csv_uuid_perms = IsProjectOwner()
regenerate_issues_csv_uuid_perms = IsProjectOwner()
regenerate_tasks_csv_uuid_perms = IsProjectOwner()
star_perms = IsAuthenticated()
unstar_perms = IsAuthenticated()
issues_stats_perms = HasProjectPerm('view_project')

View File

@ -22,6 +22,7 @@ from taiga.base import exceptions as exc
from taiga.base.decorators import list_route
from taiga.base.api import ModelCrudViewSet
from taiga.projects.models import Project
from django.http import HttpResponse
from taiga.projects.notifications.mixins import WatchedResourceMixin
from taiga.projects.history.mixins import HistoryResourceMixin
@ -71,6 +72,19 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
task = get_object_or_404(models.Task, ref=ref, project_id=project_id)
return self.retrieve(request, pk=task.pk)
@list_route(methods=["GET"])
def csv(self, request):
uuid = request.QUERY_PARAMS.get("uuid", None)
if uuid is None:
return response.NotFound()
project = get_object_or_404(Project, tasks_csv_uuid=uuid)
queryset = project.tasks.all().order_by('ref')
data = services.tasks_to_csv(queryset)
csv_response = HttpResponse(data.getvalue(), content_type='application/csv')
csv_response['Content-Disposition'] = 'attachment; filename="tasks.csv"'
return csv_response
@list_route(methods=["POST"])
def bulk_create(self, request, **kwargs):
serializer = serializers.TasksBulkSerializer(data=request.DATA)
@ -98,8 +112,8 @@ class TaskViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMixin,
self.check_permissions(request, "bulk_update_order", project)
services.update_tasks_order_in_bulk(data["bulk_tasks"],
project=project,
field=order_field)
project=project,
field=order_field)
services.snapshot_tasks_in_bulk(data["bulk_tasks"], request.user)
return response.NoContent()

View File

@ -26,5 +26,6 @@ class TaskPermission(TaigaResourcePermission):
update_perms = HasProjectPerm('modify_task')
destroy_perms = HasProjectPerm('delete_task')
list_perms = AllowAny()
csv_perms = AllowAny()
bulk_create_perms = HasProjectPerm('add_task')
bulk_update_order_perms = HasProjectPerm('modify_task')

View File

@ -14,6 +14,9 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import io
import csv
from taiga.base.utils import db, text
from taiga.projects.history.services import take_snapshot
from taiga.events import events
@ -75,3 +78,34 @@ def snapshot_tasks_in_bulk(bulk_data, user):
take_snapshot(task, user=user)
except models.UserStory.DoesNotExist:
pass
def tasks_to_csv(queryset):
csv_data = io.StringIO()
fieldnames = ["ref", "subject", "description", "user_story", "milestone", "owner",
"owner_full_name", "assigned_to", "assigned_to_full_name",
"status", "is_iocaine", "is_closed", "us_order",
"taskboard_order", "attachments", "external_reference"]
writer = csv.DictWriter(csv_data, fieldnames=fieldnames)
writer.writeheader()
for task in queryset:
writer.writerow({
"ref": task.ref,
"subject": task.subject,
"description": task.description,
"user_story": task.user_story.ref if task.user_story else None,
"milestone": task.milestone.name if task.milestone else None,
"owner": task.owner.username,
"owner_full_name": task.owner.get_full_name(),
"assigned_to": task.assigned_to.username if task.assigned_to else None,
"assigned_to_full_name": task.assigned_to.get_full_name() if task.assigned_to else None,
"status": task.status.name,
"is_iocaine": task.is_iocaine,
"is_closed": task.status.is_closed,
"us_order": task.us_order,
"taskboard_order": task.taskboard_order,
"attachments": task.attachments.count(),
"external_reference": task.external_reference,
})
return csv_data

View File

@ -22,6 +22,7 @@ from django.apps import apps
from django.db import transaction
from django.utils.translation import ugettext as _
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpResponse
from taiga.base import filters
from taiga.base import exceptions as exc
@ -102,6 +103,19 @@ class UserStoryViewSet(OCCResourceMixin, HistoryResourceMixin, WatchedResourceMi
userstory = get_object_or_404(models.UserStory, ref=ref, project_id=project_id)
return self.retrieve(request, pk=userstory.pk)
@list_route(methods=["GET"])
def csv(self, request):
uuid = request.QUERY_PARAMS.get("uuid", None)
if uuid is None:
return response.NotFound()
project = get_object_or_404(Project, userstories_csv_uuid=uuid)
queryset = project.user_stories.all().order_by('ref')
data = services.userstories_to_csv(project, queryset)
csv_response = HttpResponse(data.getvalue(), content_type='application/csv')
csv_response['Content-Disposition'] = 'attachment; filename="userstories.csv"'
return csv_response
@list_route(methods=["POST"])
def bulk_create(self, request, **kwargs):
serializer = serializers.UserStoriesBulkSerializer(data=request.DATA)

View File

@ -25,5 +25,6 @@ class UserStoryPermission(TaigaResourcePermission):
update_perms = HasProjectPerm('modify_us')
destroy_perms = HasProjectPerm('delete_us')
list_perms = AllowAny()
csv_perms = AllowAny()
bulk_create_perms = IsAuthenticated() & (HasProjectPerm('add_us_to_project') | HasProjectPerm('add_us'))
bulk_update_order_perms = HasProjectPerm('modify_us')

View File

@ -14,6 +14,9 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import csv
import io
from django.utils import timezone
from taiga.base.utils import db, text
@ -104,3 +107,57 @@ def open_userstory(us):
us.is_closed = False
us.finish_date = None
us.save(update_fields=["is_closed", "finish_date"])
def userstories_to_csv(project,queryset):
csv_data = io.StringIO()
fieldnames = ["ref", "subject", "description", "milestone", "owner",
"owner_full_name", "assigned_to", "assigned_to_full_name",
"status", "is_closed"]
for role in project.roles.filter(computable=True).order_by('name'):
fieldnames.append("{}-points".format(role.slug))
fieldnames.append("total-points")
fieldnames += ["backlog_order", "sprint_order", "kanban_order",
"created_date", "modified_date", "finish_date",
"client_requirement", "team_requirement", "attachments",
"generated_from_issue", "external_reference", "tasks"]
writer = csv.DictWriter(csv_data, fieldnames=fieldnames)
writer.writeheader()
for us in queryset:
row = {
"ref": us.ref,
"subject": us.subject,
"description": us.description,
"milestone": us.milestone.name if us.milestone else None,
"owner": us.owner.username,
"owner_full_name": us.owner.get_full_name(),
"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,
"status": us.status.name,
"is_closed": us.is_closed,
"backlog_order": us.backlog_order,
"sprint_order": us.sprint_order,
"kanban_order": us.kanban_order,
"created_date": us.created_date,
"modified_date": us.modified_date,
"finish_date": us.finish_date,
"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,
"external_reference": us.external_reference,
"tasks": ",".join([str(task.ref) for task in us.tasks.all()]),
}
for role in us.project.roles.filter(computable=True).order_by('name'):
if us.role_points.filter(role_id=role.id).count() == 1:
row["{}-points".format(role.slug)] = us.role_points.get(role_id=role.id).points.value
else:
row["{}-points".format(role.slug)] = 0
row['total-points'] = us.get_total_points()
writer.writerow(row)
return csv_data

View File

@ -437,3 +437,27 @@ def test_issue_voters_retrieve(client, data):
results = helper_test_http_method(client, 'get', private_url2, None, users)
assert results == [401, 403, 403, 200, 200]
def test_issues_csv(client, data):
url = reverse('issues-csv')
csv_public_uuid = data.public_project.issues_csv_uuid
csv_private1_uuid = data.private_project1.issues_csv_uuid
csv_private2_uuid = data.private_project1.issues_csv_uuid
users = [
None,
data.registered_user,
data.project_member_without_perms,
data.project_member_with_perms,
data.project_owner
]
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users)
assert results == [200, 200, 200, 200, 200]
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users)
assert results == [200, 200, 200, 200, 200]
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users)
assert results == [200, 200, 200, 200, 200]

View File

@ -369,3 +369,66 @@ def test_invitations_retrieve(client, data):
]
results = helper_test_http_method(client, 'get', url, None, users)
assert results == [200, 200, 200, 200]
def test_regenerate_userstories_csv_uuid(client, data):
public_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.public_project.pk})
private1_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.private_project1.pk})
private2_url = reverse('projects-regenerate-userstories-csv-uuid', kwargs={"pk": data.private_project2.pk})
users = [
None,
data.registered_user,
data.project_member_with_perms,
data.project_owner
]
results = helper_test_http_method(client, 'post', public_url, None, users)
assert results == [401, 403, 403, 200]
results = helper_test_http_method(client, 'post', private1_url, None, users)
assert results == [401, 403, 403, 200]
results = helper_test_http_method(client, 'post', private2_url, None, users)
assert results == [404, 404, 403, 200]
def test_regenerate_tasks_csv_uuid(client, data):
public_url = reverse('projects-regenerate-tasks-csv-uuid', kwargs={"pk": data.public_project.pk})
private1_url = reverse('projects-regenerate-tasks-csv-uuid', kwargs={"pk": data.private_project1.pk})
private2_url = reverse('projects-regenerate-tasks-csv-uuid', kwargs={"pk": data.private_project2.pk})
users = [
None,
data.registered_user,
data.project_member_with_perms,
data.project_owner
]
results = helper_test_http_method(client, 'post', public_url, None, users)
assert results == [401, 403, 403, 200]
results = helper_test_http_method(client, 'post', private1_url, None, users)
assert results == [401, 403, 403, 200]
results = helper_test_http_method(client, 'post', private2_url, None, users)
assert results == [404, 404, 403, 200]
def test_regenerate_issues_csv_uuid(client, data):
public_url = reverse('projects-regenerate-issues-csv-uuid', kwargs={"pk": data.public_project.pk})
private1_url = reverse('projects-regenerate-issues-csv-uuid', kwargs={"pk": data.private_project1.pk})
private2_url = reverse('projects-regenerate-issues-csv-uuid', kwargs={"pk": data.private_project2.pk})
users = [
None,
data.registered_user,
data.project_member_with_perms,
data.project_owner
]
results = helper_test_http_method(client, 'post', public_url, None, users)
assert results == [401, 403, 403, 200]
results = helper_test_http_method(client, 'post', private1_url, None, users)
assert results == [401, 403, 403, 200]
results = helper_test_http_method(client, 'post', private2_url, None, users)
assert results == [404, 404, 403, 200]

View File

@ -307,3 +307,27 @@ def test_task_action_bulk_create(client, data):
})
results = helper_test_http_method(client, 'post', url, bulk_data, users)
assert results == [401, 403, 403, 200, 200]
def test_tasks_csv(client, data):
url = reverse('tasks-csv')
csv_public_uuid = data.public_project.tasks_csv_uuid
csv_private1_uuid = data.private_project1.tasks_csv_uuid
csv_private2_uuid = data.private_project1.tasks_csv_uuid
users = [
None,
data.registered_user,
data.project_member_without_perms,
data.project_member_with_perms,
data.project_owner
]
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users)
assert results == [200, 200, 200, 200, 200]
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users)
assert results == [200, 200, 200, 200, 200]
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users)
assert results == [200, 200, 200, 200, 200]

View File

@ -308,3 +308,27 @@ def test_user_story_action_bulk_update_order(client, data):
})
results = helper_test_http_method(client, 'post', url, post_data, users)
assert results == [401, 403, 403, 204, 204]
def test_user_stories_csv(client, data):
url = reverse('userstories-csv')
csv_public_uuid = data.public_project.userstories_csv_uuid
csv_private1_uuid = data.private_project1.userstories_csv_uuid
csv_private2_uuid = data.private_project1.userstories_csv_uuid
users = [
None,
data.registered_user,
data.project_member_without_perms,
data.project_member_with_perms,
data.project_owner
]
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_public_uuid), None, users)
assert results == [200, 200, 200, 200, 200]
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private1_uuid), None, users)
assert results == [200, 200, 200, 200, 200]
results = helper_test_http_method(client, 'get', "{}?uuid={}".format(url, csv_private2_uuid), None, users)
assert results == [200, 200, 200, 200, 200]

View File

@ -161,3 +161,21 @@ def test_api_filter_by_text_6(client):
assert response.status_code == 200
assert number_of_issues == 1
def test_get_invalid_csv(client):
url = reverse("issues-csv")
response = client.get(url)
assert response.status_code == 404
response = client.get("{}?uuid={}".format(url, "not-valid-uuid"))
assert response.status_code == 404
def test_get_valid_csv(client):
url = reverse("issues-csv")
project = f.ProjectFactory.create()
response = client.get("{}?uuid={}".format(url, project.issues_csv_uuid))
assert response.status_code == 200

View File

@ -110,3 +110,21 @@ def test_api_update_order_in_bulk(client):
assert response1.status_code == 204, response1.data
assert response2.status_code == 204, response2.data
def test_get_invalid_csv(client):
url = reverse("tasks-csv")
response = client.get(url)
assert response.status_code == 404
response = client.get("{}?uuid={}".format(url, "not-valid-uuid"))
assert response.status_code == 404
def test_get_valid_csv(client):
url = reverse("tasks-csv")
project = f.ProjectFactory.create()
response = client.get("{}?uuid={}".format(url, project.tasks_csv_uuid))
assert response.status_code == 200

View File

@ -241,3 +241,21 @@ def test_get_total_points(client):
f.RolePointsFactory.create(user_story=us_mixed, role=role2, points=points2)
assert us_mixed.get_total_points() == 1.0
def test_get_invalid_csv(client):
url = reverse("userstories-csv")
response = client.get(url)
assert response.status_code == 404
response = client.get("{}?uuid={}".format(url, "not-valid-uuid"))
assert response.status_code == 404
def test_get_valid_csv(client):
url = reverse("userstories-csv")
project = f.ProjectFactory.create()
response = client.get("{}?uuid={}".format(url, project.userstories_csv_uuid))
assert response.status_code == 200