diff --git a/pyproject.toml b/pyproject.toml index 09f3b2b5dd2444..0e160c325d3f82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -613,6 +613,7 @@ module = [ "sentry.utils.urls", "sentry.utils.uwsgi", "sentry.utils.zip", + "sentry.web.frontend.csv", "sentry_plugins.base", "tests.sentry.api.endpoints.issues.*", "tests.sentry.event_manager.test_event_manager", diff --git a/src/sentry/web/frontend/csv.py b/src/sentry/web/frontend/csv.py new file mode 100644 index 00000000000000..b90cfd49ac708b --- /dev/null +++ b/src/sentry/web/frontend/csv.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import csv +from collections.abc import Generator, Iterable +from typing import Generic, TypeVar + +from django.http import StreamingHttpResponse + +T = TypeVar("T") + + +# csv.writer doesn't provide a non-file interface +# https://docs.djangoproject.com/en/1.9/howto/outputting-csv/#streaming-large-csv-files +class Echo: + def write(self, value: str) -> str: + return value + + +class CsvResponder(Generic[T]): + def get_header(self) -> tuple[str, ...]: + raise NotImplementedError + + def get_row(self, item: T) -> tuple[str, ...]: + raise NotImplementedError + + def respond(self, iterable: Iterable[T], filename: str) -> StreamingHttpResponse: + def row_iter() -> Generator[tuple[str, ...], None, None]: + header = self.get_header() + if header: + yield header + for item in iterable: + yield self.get_row(item) + + pseudo_buffer = Echo() + writer = csv.writer(pseudo_buffer) + return StreamingHttpResponse( + (writer.writerow(r) for r in row_iter()), + content_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}.csv"'}, + ) diff --git a/src/sentry/web/frontend/group_tag_export.py b/src/sentry/web/frontend/group_tag_export.py index adc1236cbdee37..3c9fe1fdfc1254 100644 --- a/src/sentry/web/frontend/group_tag_export.py +++ b/src/sentry/web/frontend/group_tag_export.py @@ -1,29 +1,36 @@ +from __future__ import annotations + from django.http import Http404 +from django.http.response import HttpResponseBase from rest_framework.request import Request -from rest_framework.response import Response from sentry.api.base import EnvironmentMixin from sentry.data_export.base import ExportError from sentry.data_export.processors.issues_by_tag import IssuesByTagProcessor from sentry.models.environment import Environment +from sentry.tagstore.types import GroupTagValue from sentry.web.frontend.base import ProjectView, region_silo_view -from sentry.web.frontend.mixins.csv import CsvMixin +from sentry.web.frontend.csv import CsvResponder -@region_silo_view -class GroupTagExportView(ProjectView, CsvMixin, EnvironmentMixin): - required_scope = "event:read" +class GroupTagCsvResponder(CsvResponder[GroupTagValue]): + def __init__(self, key: str) -> None: + self.key = key - def get_header(self, key): - return tuple(IssuesByTagProcessor.get_header_fields(key)) + def get_header(self) -> tuple[str, ...]: + return tuple(IssuesByTagProcessor.get_header_fields(self.key)) - def get_row(self, item, key): - fields = IssuesByTagProcessor.get_header_fields(key) - item_dict = IssuesByTagProcessor.serialize_row(item, key) - return (item_dict[field] for field in fields) + def get_row(self, item: GroupTagValue) -> tuple[str, ...]: + fields = IssuesByTagProcessor.get_header_fields(self.key) + item_dict = IssuesByTagProcessor.serialize_row(item, self.key) + return tuple(item_dict[field] for field in fields) - def get(self, request: Request, organization, project, group_id, key) -> Response: +@region_silo_view +class GroupTagExportView(ProjectView, EnvironmentMixin): + required_scope = "event:read" + + def get(self, request: Request, organization, project, group_id, key) -> HttpResponseBase: # If the environment doesn't exist then the tag can't possibly exist try: environment_id = self._get_environment_id_from_request(request, project.organization_id) @@ -43,4 +50,4 @@ def get(self, request: Request, organization, project, group_id, key) -> Respons filename = f"{processor.group.qualified_short_id or processor.group.id}-{key}" - return self.to_csv_response(processor.get_raw_data(), filename, key=key) + return GroupTagCsvResponder(key).respond(processor.get_raw_data(), filename) diff --git a/src/sentry/web/frontend/mixins/csv.py b/src/sentry/web/frontend/mixins/csv.py index e57ca8381b01e6..41375964c97896 100644 --- a/src/sentry/web/frontend/mixins/csv.py +++ b/src/sentry/web/frontend/mixins/csv.py @@ -11,6 +11,8 @@ def write(self, value): class CsvMixin: + """deprecated: will be removed! use sentry.web.csv.CsvResponder instead!""" + def get_header(self, **kwargs): return ()