[Backport] Add gzip support to exports
parent
ed0a650dc9
commit
520f383449
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
import codecs
|
import codecs
|
||||||
import uuid
|
import uuid
|
||||||
|
import gzip
|
||||||
|
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
@ -64,15 +65,23 @@ class ProjectExporterViewSet(mixins.ImportThrottlingPolicyMixin, GenericViewSet)
|
||||||
project = get_object_or_404(self.get_queryset(), pk=pk)
|
project = get_object_or_404(self.get_queryset(), pk=pk)
|
||||||
self.check_permissions(request, 'export_project', project)
|
self.check_permissions(request, 'export_project', project)
|
||||||
|
|
||||||
|
dump_format = request.QUERY_PARAMS.get("dump_format", None)
|
||||||
|
|
||||||
if settings.CELERY_ENABLED:
|
if settings.CELERY_ENABLED:
|
||||||
task = tasks.dump_project.delay(request.user, project)
|
task = tasks.dump_project.delay(request.user, project, dump_format)
|
||||||
tasks.delete_project_dump.apply_async((project.pk, project.slug, task.id),
|
tasks.delete_project_dump.apply_async((project.pk, project.slug, task.id, dump_format),
|
||||||
countdown=settings.EXPORTS_TTL)
|
countdown=settings.EXPORTS_TTL)
|
||||||
return response.Accepted({"export_id": task.id})
|
return response.Accepted({"export_id": task.id})
|
||||||
|
|
||||||
|
if dump_format == "gzip":
|
||||||
|
path = "exports/{}/{}-{}.json.gz".format(project.pk, project.slug, uuid.uuid4().hex)
|
||||||
|
storage_path = default_storage.path(path)
|
||||||
|
with default_storage.open(storage_path, mode="wb") as outfile:
|
||||||
|
services.render_project(project, gzip.GzipFile(fileobj=outfile))
|
||||||
|
else:
|
||||||
path = "exports/{}/{}-{}.json".format(project.pk, project.slug, uuid.uuid4().hex)
|
path = "exports/{}/{}-{}.json".format(project.pk, project.slug, uuid.uuid4().hex)
|
||||||
storage_path = default_storage.path(path)
|
storage_path = default_storage.path(path)
|
||||||
with default_storage.open(storage_path, mode="w") as outfile:
|
with default_storage.open(storage_path, mode="wb") as outfile:
|
||||||
services.render_project(project, outfile)
|
services.render_project(project, outfile)
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
|
|
|
@ -22,6 +22,7 @@ from taiga.projects.models import Project
|
||||||
from taiga.export_import.services import render_project
|
from taiga.export_import.services import render_project
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import gzip
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
@ -39,6 +40,13 @@ class Command(BaseCommand):
|
||||||
metavar="DIR",
|
metavar="DIR",
|
||||||
help="Directory to save the json files. ('./' by default)")
|
help="Directory to save the json files. ('./' by default)")
|
||||||
|
|
||||||
|
parser.add_argument("-f", "--format",
|
||||||
|
action="store",
|
||||||
|
dest="format",
|
||||||
|
default="plain",
|
||||||
|
metavar="[plain|gzip]",
|
||||||
|
help="Format to the output file plain json or gzipped json. ('plain' by default)")
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
dst_dir = options["dst_dir"]
|
dst_dir = options["dst_dir"]
|
||||||
|
|
||||||
|
@ -56,8 +64,13 @@ class Command(BaseCommand):
|
||||||
except Project.DoesNotExist:
|
except Project.DoesNotExist:
|
||||||
raise CommandError("Project '{}' does not exist".format(project_slug))
|
raise CommandError("Project '{}' does not exist".format(project_slug))
|
||||||
|
|
||||||
|
if options["format"] == "gzip":
|
||||||
|
dst_file = os.path.join(dst_dir, "{}.json.gz".format(project_slug))
|
||||||
|
with gzip.GzipFile(dst_file, "wb") as f:
|
||||||
|
render_project(project, f)
|
||||||
|
else:
|
||||||
dst_file = os.path.join(dst_dir, "{}.json".format(project_slug))
|
dst_file = os.path.join(dst_dir, "{}.json".format(project_slug))
|
||||||
with open(dst_file, "w") as f:
|
with open(dst_file, "wb") as f:
|
||||||
render_project(project, f)
|
render_project(project, f)
|
||||||
|
|
||||||
print("-> Generate dump of project '{}' in '{}'".format(project.name, dst_file))
|
print("-> Generate dump of project '{}' in '{}'".format(project.name, dst_file))
|
||||||
|
|
|
@ -34,13 +34,13 @@ from .. import serializers
|
||||||
|
|
||||||
def render_project(project, outfile, chunk_size = 8190):
|
def render_project(project, outfile, chunk_size = 8190):
|
||||||
serializer = serializers.ProjectExportSerializer(project)
|
serializer = serializers.ProjectExportSerializer(project)
|
||||||
outfile.write('{\n')
|
outfile.write(b'{\n')
|
||||||
|
|
||||||
first_field = True
|
first_field = True
|
||||||
for field_name in serializer.fields.keys():
|
for field_name in serializer.fields.keys():
|
||||||
# Avoid writing "," in the last element
|
# Avoid writing "," in the last element
|
||||||
if not first_field:
|
if not first_field:
|
||||||
outfile.write(",\n")
|
outfile.write(b",\n")
|
||||||
else:
|
else:
|
||||||
first_field = False
|
first_field = False
|
||||||
|
|
||||||
|
@ -56,7 +56,7 @@ def render_project(project, outfile, chunk_size = 8190):
|
||||||
value = value.select_related('severity', 'priority', 'type')
|
value = value.select_related('severity', 'priority', 'type')
|
||||||
value = value.prefetch_related('history_entry', 'attachments')
|
value = value.prefetch_related('history_entry', 'attachments')
|
||||||
|
|
||||||
outfile.write('"{}": [\n'.format(field_name))
|
outfile.write('"{}": [\n'.format(field_name).encode())
|
||||||
|
|
||||||
attachments_field = field.fields.pop("attachments", None)
|
attachments_field = field.fields.pop("attachments", None)
|
||||||
if attachments_field:
|
if attachments_field:
|
||||||
|
@ -66,20 +66,20 @@ def render_project(project, outfile, chunk_size = 8190):
|
||||||
for item in value.iterator():
|
for item in value.iterator():
|
||||||
# Avoid writing "," in the last element
|
# Avoid writing "," in the last element
|
||||||
if not first_item:
|
if not first_item:
|
||||||
outfile.write(",\n")
|
outfile.write(b",\n")
|
||||||
else:
|
else:
|
||||||
first_item = False
|
first_item = False
|
||||||
|
|
||||||
|
|
||||||
dumped_value = json.dumps(field.to_native(item))
|
dumped_value = json.dumps(field.to_native(item))
|
||||||
writing_value = dumped_value[:-1]+ ',\n "attachments": [\n'
|
writing_value = dumped_value[:-1]+ ',\n "attachments": [\n'
|
||||||
outfile.write(writing_value)
|
outfile.write(writing_value.encode())
|
||||||
|
|
||||||
first_attachment = True
|
first_attachment = True
|
||||||
for attachment in item.attachments.iterator():
|
for attachment in item.attachments.iterator():
|
||||||
# Avoid writing "," in the last element
|
# Avoid writing "," in the last element
|
||||||
if not first_attachment:
|
if not first_attachment:
|
||||||
outfile.write(",\n")
|
outfile.write(b",\n")
|
||||||
else:
|
else:
|
||||||
first_attachment = False
|
first_attachment = False
|
||||||
|
|
||||||
|
@ -88,7 +88,7 @@ def render_project(project, outfile, chunk_size = 8190):
|
||||||
attached_file_serializer = attachment_serializer.fields.pop("attached_file")
|
attached_file_serializer = attachment_serializer.fields.pop("attached_file")
|
||||||
dumped_value = json.dumps(attachment_serializer.data)
|
dumped_value = json.dumps(attachment_serializer.data)
|
||||||
dumped_value = dumped_value[:-1] + ',\n "attached_file":{\n "data":"'
|
dumped_value = dumped_value[:-1] + ',\n "attached_file":{\n "data":"'
|
||||||
outfile.write(dumped_value)
|
outfile.write(dumped_value.encode())
|
||||||
|
|
||||||
# We write the attached_files by chunks so the memory used is not increased
|
# We write the attached_files by chunks so the memory used is not increased
|
||||||
attachment_file = attachment.attached_file
|
attachment_file = attachment.attached_file
|
||||||
|
@ -99,32 +99,32 @@ def render_project(project, outfile, chunk_size = 8190):
|
||||||
if not bin_data:
|
if not bin_data:
|
||||||
break
|
break
|
||||||
|
|
||||||
b64_data = base64.b64encode(bin_data).decode('utf-8')
|
b64_data = base64.b64encode(bin_data)
|
||||||
outfile.write(b64_data)
|
outfile.write(b64_data)
|
||||||
|
|
||||||
outfile.write('", \n "name":"{}"}}\n}}'.format(
|
outfile.write('", \n "name":"{}"}}\n}}'.format(
|
||||||
os.path.basename(attachment_file.name)))
|
os.path.basename(attachment_file.name)).encode())
|
||||||
|
|
||||||
outfile.write(']}')
|
outfile.write(b']}')
|
||||||
outfile.flush()
|
outfile.flush()
|
||||||
gc.collect()
|
gc.collect()
|
||||||
outfile.write(']')
|
outfile.write(b']')
|
||||||
else:
|
else:
|
||||||
value = field.field_to_native(project, field_name)
|
value = field.field_to_native(project, field_name)
|
||||||
outfile.write('"{}": {}'.format(field_name, json.dumps(value)))
|
outfile.write('"{}": {}'.format(field_name, json.dumps(value)).encode())
|
||||||
|
|
||||||
# Generate the timeline
|
# Generate the timeline
|
||||||
outfile.write(',\n"timeline": [\n')
|
outfile.write(b',\n"timeline": [\n')
|
||||||
first_timeline = True
|
first_timeline = True
|
||||||
for timeline_item in get_project_timeline(project).iterator():
|
for timeline_item in get_project_timeline(project).iterator():
|
||||||
# Avoid writing "," in the last element
|
# Avoid writing "," in the last element
|
||||||
if not first_timeline:
|
if not first_timeline:
|
||||||
outfile.write(",\n")
|
outfile.write(b",\n")
|
||||||
else:
|
else:
|
||||||
first_timeline = False
|
first_timeline = False
|
||||||
|
|
||||||
dumped_value = json.dumps(serializers.TimelineExportSerializer(timeline_item).data)
|
dumped_value = json.dumps(serializers.TimelineExportSerializer(timeline_item).data)
|
||||||
outfile.write(dumped_value)
|
outfile.write(dumped_value.encode())
|
||||||
|
|
||||||
outfile.write(']}\n')
|
outfile.write(b']}\n')
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
import gzip
|
||||||
|
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
@ -41,15 +42,21 @@ import resource
|
||||||
|
|
||||||
|
|
||||||
@app.task(bind=True)
|
@app.task(bind=True)
|
||||||
def dump_project(self, user, project):
|
def dump_project(self, user, project, dump_format):
|
||||||
|
try:
|
||||||
|
if dump_format == "gzip":
|
||||||
|
path = "exports/{}/{}-{}.json.gz".format(project.pk, project.slug, self.request.id)
|
||||||
|
storage_path = default_storage.path(path)
|
||||||
|
with default_storage.open(storage_path, mode="wb") as outfile:
|
||||||
|
services.render_project(project, gzip.GzipFile(fileobj=outfile))
|
||||||
|
else:
|
||||||
path = "exports/{}/{}-{}.json".format(project.pk, project.slug, self.request.id)
|
path = "exports/{}/{}-{}.json".format(project.pk, project.slug, self.request.id)
|
||||||
storage_path = default_storage.path(path)
|
storage_path = default_storage.path(path)
|
||||||
|
with default_storage.open(storage_path, mode="wb") as outfile:
|
||||||
try:
|
|
||||||
url = default_storage.url(path)
|
|
||||||
with default_storage.open(storage_path, mode="w") as outfile:
|
|
||||||
services.render_project(project, outfile)
|
services.render_project(project, outfile)
|
||||||
|
|
||||||
|
url = default_storage.url(path)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Error
|
# Error
|
||||||
ctx = {
|
ctx = {
|
||||||
|
@ -75,8 +82,12 @@ def dump_project(self, user, project):
|
||||||
|
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def delete_project_dump(project_id, project_slug, task_id):
|
def delete_project_dump(project_id, project_slug, task_id, dump_format):
|
||||||
default_storage.delete("exports/{}/{}-{}.json".format(project_id, project_slug, task_id))
|
if dump_format == "gzip":
|
||||||
|
path = "exports/{}/{}-{}.json.gz".format(project_id, project_slug, task_id)
|
||||||
|
else:
|
||||||
|
path = "exports/{}/{}-{}.json".format(project_id, project_slug, task_id)
|
||||||
|
default_storage.delete(path)
|
||||||
|
|
||||||
|
|
||||||
ADMIN_ERROR_LOAD_PROJECT_DUMP_MESSAGE = _("""
|
ADMIN_ERROR_LOAD_PROJECT_DUMP_MESSAGE = _("""
|
||||||
|
|
|
@ -53,6 +53,24 @@ def test_valid_project_export_with_celery_disabled(client, settings):
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
response_data = response.data
|
response_data = response.data
|
||||||
assert "url" in response_data
|
assert "url" in response_data
|
||||||
|
assert response_data["url"].endswith(".json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_project_export_with_celery_disabled_and_gzip(client, settings):
|
||||||
|
settings.CELERY_ENABLED = False
|
||||||
|
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
project = f.ProjectFactory.create(owner=user)
|
||||||
|
f.MembershipFactory(project=project, user=user, is_admin=True)
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("exporter-detail", args=[project.pk])
|
||||||
|
|
||||||
|
response = client.get(url+"?dump_format=gzip", content_type="application/json")
|
||||||
|
assert response.status_code == 200
|
||||||
|
response_data = response.data
|
||||||
|
assert "url" in response_data
|
||||||
|
assert response_data["url"].endswith(".gz")
|
||||||
|
|
||||||
|
|
||||||
def test_valid_project_export_with_celery_enabled(client, settings):
|
def test_valid_project_export_with_celery_enabled(client, settings):
|
||||||
|
@ -72,7 +90,29 @@ def test_valid_project_export_with_celery_enabled(client, settings):
|
||||||
response_data = response.data
|
response_data = response.data
|
||||||
assert "export_id" in response_data
|
assert "export_id" in response_data
|
||||||
|
|
||||||
args = (project.id, project.slug, response_data["export_id"],)
|
args = (project.id, project.slug, response_data["export_id"], None)
|
||||||
|
kwargs = {"countdown": settings.EXPORTS_TTL}
|
||||||
|
delete_project_dump_mock.apply_async.assert_called_once_with(args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_project_export_with_celery_enabled_and_gzip(client, settings):
|
||||||
|
settings.CELERY_ENABLED = True
|
||||||
|
|
||||||
|
user = f.UserFactory.create()
|
||||||
|
project = f.ProjectFactory.create(owner=user)
|
||||||
|
f.MembershipFactory(project=project, user=user, is_admin=True)
|
||||||
|
client.login(user)
|
||||||
|
|
||||||
|
url = reverse("exporter-detail", args=[project.pk])
|
||||||
|
|
||||||
|
#delete_project_dump task should have been launched
|
||||||
|
with mock.patch('taiga.export_import.tasks.delete_project_dump') as delete_project_dump_mock:
|
||||||
|
response = client.get(url+"?dump_format=gzip", content_type="application/json")
|
||||||
|
assert response.status_code == 202
|
||||||
|
response_data = response.data
|
||||||
|
assert "export_id" in response_data
|
||||||
|
|
||||||
|
args = (project.id, project.slug, response_data["export_id"], "gzip")
|
||||||
kwargs = {"countdown": settings.EXPORTS_TTL}
|
kwargs = {"countdown": settings.EXPORTS_TTL}
|
||||||
delete_project_dump_mock.apply_async.assert_called_once_with(args, **kwargs)
|
delete_project_dump_mock.apply_async.assert_called_once_with(args, **kwargs)
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
def test_export_issue_finish_date(client):
|
def test_export_issue_finish_date(client):
|
||||||
issue = f.IssueFactory.create(finished_date="2014-10-22")
|
issue = f.IssueFactory.create(finished_date="2014-10-22")
|
||||||
output = io.StringIO()
|
output = io.BytesIO()
|
||||||
render_project(issue.project, output)
|
render_project(issue.project, output)
|
||||||
project_data = json.loads(output.getvalue())
|
project_data = json.loads(output.getvalue())
|
||||||
finish_date = project_data["issues"][0]["finished_date"]
|
finish_date = project_data["issues"][0]["finished_date"]
|
||||||
|
@ -37,7 +37,7 @@ def test_export_issue_finish_date(client):
|
||||||
|
|
||||||
def test_export_user_story_finish_date(client):
|
def test_export_user_story_finish_date(client):
|
||||||
user_story = f.UserStoryFactory.create(finish_date="2014-10-22")
|
user_story = f.UserStoryFactory.create(finish_date="2014-10-22")
|
||||||
output = io.StringIO()
|
output = io.BytesIO()
|
||||||
render_project(user_story.project, output)
|
render_project(user_story.project, output)
|
||||||
project_data = json.loads(output.getvalue())
|
project_data = json.loads(output.getvalue())
|
||||||
finish_date = project_data["user_stories"][0]["finish_date"]
|
finish_date = project_data["user_stories"][0]["finish_date"]
|
||||||
|
|
Loading…
Reference in New Issue