Skip to content

Commit

Permalink
feat: Add UI for event attachments
Browse files Browse the repository at this point in the history
  • Loading branch information
dcramer committed Jul 9, 2018
1 parent e83b408 commit c440bd6
Show file tree
Hide file tree
Showing 8 changed files with 301 additions and 2 deletions.
15 changes: 14 additions & 1 deletion bin/load-mocks
Expand Up @@ -20,7 +20,7 @@ from pytz import utc
from sentry import buffer, roles, tsdb
from sentry.event_manager import HashDiscarded
from sentry.models import (
Activity, Broadcast, Commit, CommitAuthor, CommitFileChange, Deploy, Event,
Activity, Broadcast, Commit, CommitAuthor, CommitFileChange, Deploy, EventAttachment, Event,
Environment, File, Group, GroupMeta, GroupRelease, GroupTombstone, Organization,
OrganizationAccessRequest, OrganizationMember, Project, Release,
ReleaseCommit, ReleaseEnvironment, ReleaseProjectEnvironment, ReleaseFile, Repository,
Expand Down Expand Up @@ -531,6 +531,19 @@ def main(num_events=1, extra_events=False):
user=generate_user(),
)

EventAttachment.objects.create(
project_id=project.id,
group_id=event1.group_id,
event_id=event1.event_id,
name='example-logfile.txt',
file=File.objects.get_or_create(
name='example-logfile.txt',
type='text/plain',
checksum='abcde' * 8,
size=13043,
)[0],
)

event2 = create_sample_event(
project=project,
platform='javascript',
Expand Down
73 changes: 73 additions & 0 deletions src/sentry/api/endpoints/event_attachment_details.py
@@ -0,0 +1,73 @@
from __future__ import absolute_import

import posixpath
import six

try:
from django.http import (CompatibleStreamingHttpResponse as StreamingHttpResponse)
except ImportError:
from django.http import StreamingHttpResponse

from sentry import features
from sentry.api.bases.project import ProjectEndpoint
from sentry.models import Event, EventAttachment


class EventAttachmentDetailsEndpoint(ProjectEndpoint):
def download(self, attachment):
file = attachment.file
fp = file.getfile()
response = StreamingHttpResponse(
iter(lambda: fp.read(4096), b''),
content_type=file.headers.get('content-type', 'application/octet-stream'),
)
response['Content-Length'] = file.size
response['Content-Disposition'] = 'attachment; filename="%s"' % posixpath.basename(
" ".join(attachment.name.split())
)
return response

def get(self, request, project, event_id, attachment_id):
"""
Retrieve an Attachment
``````````````````````
:pparam string organization_slug: the slug of the organization the
issues belong to.
:pparam string project_slug: the slug of the project the event
belongs to.
:pparam string event_id: the id of the event.
:pparam string attachment_id: the id of the attachment.
:auth: required
"""
if not features.has('organizations:event-attachments', project.organization, actor=request.user):
return self.respond(status=404)

try:
event = Event.objects.get(
id=event_id,
project_id=project.id,
)
except Event.DoesNotExist:
return self.respond({'detail': 'Event not found'}, status=404)

try:
attachment = EventAttachment.objects.filter(
project_id=project.id,
event_id=event.event_id,
id=attachment_id,
).select_related('file').get()
except EventAttachment.DoesNotExist:
return self.respond({'detail': 'Attachment not found'}, status=404)

if request.GET.get('download') is not None:
return self.download(attachment)

return self.respond({
'id': six.text_type(attachment.id),
'name': attachment.name,
'headers': attachment.file.headers,
'size': attachment.file.size,
'sha1': attachment.file.checksum,
'dateCreated': attachment.file.timestamp,
})
2 changes: 2 additions & 0 deletions src/sentry/api/endpoints/event_attachments.py
Expand Up @@ -14,6 +14,8 @@ def get(self, request, project, event_id):
Retrieve attachments for an event
`````````````````````````````````
:pparam string organization_slug: the slug of the organization the
issues belong to.
:pparam string project_slug: the slug of the project the event
belongs to.
:pparam string event_id: the id of the event.
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/api/serializers/models/organization.py
Expand Up @@ -140,6 +140,8 @@ def serialize(self, obj, attrs, user):
feature_list.append('shared-issues')
if getattr(obj.flags, 'require_2fa'):
feature_list.append('require-2fa')
if features.has('organizations:event-attachments', obj, actor=user):
feature_list.append('event-attachments')

context = super(DetailedOrganizationSerializer, self).serialize(obj, attrs, user)
max_rate = quotas.get_maximum_quota(obj)
Expand Down
7 changes: 6 additions & 1 deletion src/sentry/api/urls.py
Expand Up @@ -19,6 +19,7 @@
from .endpoints.broadcast_index import BroadcastIndexEndpoint
from .endpoints.catchall import CatchallEndpoint
from .endpoints.chunk import ChunkUploadEndpoint
from .endpoints.event_attachment_details import EventAttachmentDetailsEndpoint
from .endpoints.event_attachments import EventAttachmentsEndpoint
from .endpoints.event_details import EventDetailsEndpoint
from .endpoints.event_owners import EventOwnersEndpoint
Expand Down Expand Up @@ -664,7 +665,11 @@
EventAttachmentsEndpoint.as_view(),
name='sentry-api-0-event-attachments'
),

url(
r'^projects/(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/events/(?P<event_id>[\w-]+)/attachments/(?P<attachment_id>[\w-]+)/$',
EventAttachmentDetailsEndpoint.as_view(),
name='sentry-api-0-event-attachment-details'
),
url(
r'^projects/(?P<organization_slug>[^\/]+)/(?P<project_slug>[^\/]+)/events/(?P<event_id>[\w-]+)/committers/$',
EventFileCommittersEndpoint.as_view(),
Expand Down
109 changes: 109 additions & 0 deletions src/sentry/static/sentry/app/components/events/eventAttachments.jsx
@@ -0,0 +1,109 @@
import PropTypes from 'prop-types';
import React from 'react';
import createReactClass from 'create-react-class';
import {Box} from 'grid-emotion';

import ApiMixin from 'app/mixins/apiMixin';
import FileSize from 'app/components/fileSize';
import GroupState from 'app/mixins/groupState';

import {t} from 'app/locale';

import {Panel, PanelBody, PanelItem} from 'app/components/panels';

export default createReactClass({
displayName: 'EventAttachments',

propTypes: {
event: PropTypes.object.isRequired,
orgId: PropTypes.string.isRequired,
projectId: PropTypes.string.isRequired,
},

mixins: [ApiMixin, GroupState],

getInitialState() {
return {attachmentList: undefined, expanded: false};
},

componentDidMount() {
this.fetchData(this.props.event);
},

componentWillReceiveProps(nextProps) {
if (this.props.event && nextProps.event) {
if (this.props.event.id !== nextProps.event.id) {
//two events, with different IDs
this.fetchData(nextProps.event);
}
} else if (nextProps.event) {
//going from having no event to having an event
this.fetchData(nextProps.event);
}
},

fetchData(event) {
// TODO(dcramer): this API request happens twice, and we need a store for it
if (!event) return;
this.api.request(
`/projects/${this.props.orgId}/${this.props
.projectId}/events/${event.id}/attachments/`,
{
success: (data, _, jqXHR) => {
this.setState({
attachmentList: data,
});
},
error: error => {
this.setState({
attachmentList: undefined,
});
},
}
);
},

getDownloadUrl(attachment) {
let {orgId, event, projectId} = this.props;
return `/api/0/projects/${orgId}/${projectId}/events/${event.id}/attachments/${attachment.id}/?download=1`;
},

render() {
let {attachmentList} = this.state;
if (!(attachmentList && attachmentList.length)) {
return null;
}

return (
<div className="box">
<div className="box-header">
<h3>
{t('Attachments')} ({attachmentList.length})
</h3>
<Panel>
<PanelBody>
{attachmentList.map(attachment => {
return (
<PanelItem key={attachment.id} align="center">
<Box
flex={10}
pr={1}
style={{wordWrap: 'break-word', wordBreak: 'break-all'}}
>
<a href={this.getDownloadUrl(attachment)}>
<strong>{attachment.name}</strong>
</a>
</Box>
<Box flex={1} textAlign="right">
<FileSize bytes={attachment.size} />
</Box>
</PanelItem>
);
})}
</PanelBody>
</Panel>
</div>
</div>
);
},
});
Expand Up @@ -5,6 +5,7 @@ import createReactClass from 'create-react-class';

import analytics from 'app/utils/analytics';
import {logException} from 'app/utils/logging';
import EventAttachments from 'app/components/events/eventAttachments';
import EventCause from 'app/components/events/eventCause';
import EventContexts from 'app/components/events/contexts';
import EventContextSummary from 'app/components/events/contextSummary';
Expand Down Expand Up @@ -166,6 +167,10 @@ const EventEntries = createReactClass({
{!utils.objectIsEmpty(event.device) && (
<EventDevice group={group} event={event} />
)}
{!isShare &&
features.has('event-attachments') && (
<EventAttachments event={event} orgId={orgId} projectId={project.slug} />
)}
{!utils.objectIsEmpty(event.sdk) && <EventSdk group={group} event={event} />}
{!utils.objectIsEmpty(event.sdk) &&
event.sdk.upstream.isNewer && (
Expand Down
90 changes: 90 additions & 0 deletions tests/sentry/api/endpoints/test_event_attachment_details.py
@@ -0,0 +1,90 @@
from __future__ import absolute_import

import six

from datetime import datetime
from six import BytesIO

from sentry.models import EventAttachment, File
from sentry.testutils import APITestCase


class EventAttachmentDetailsTest(APITestCase):
def test_simple(self):
self.login_as(user=self.user)

project = self.create_project()
release = self.create_release(project, self.user)
group = self.create_group(project=project, first_release=release)
event1 = self.create_event(
event_id='a',
group=group,
datetime=datetime(2016, 8, 13, 3, 8, 25),
tags={'sentry:release': release.version}
)
attachment1 = EventAttachment.objects.create(
event_id=event1.event_id,
group_id=event1.group_id,
project_id=event1.project_id,
file=File.objects.create(
name='hello.png',
type='image/png',
),
name='hello.png',
)

path = '/api/0/projects/{}/{}/events/{}/attachments/{}/'.format(
event1.project.organization.slug,
event1.project.slug,
event1.id,
attachment1.id,
)

with self.feature('organizations:event-attachments'):
response = self.client.get(path)

assert response.status_code == 200, response.content
assert response.data['id'] == six.text_type(attachment1.id)

def test_download(self):
self.login_as(user=self.user)

project = self.create_project()
release = self.create_release(project, self.user)
group = self.create_group(project=project, first_release=release)
event1 = self.create_event(
event_id='a',
group=group,
datetime=datetime(2016, 8, 13, 3, 8, 25),
tags={'sentry:release': release.version}
)

file1 = File.objects.create(
name='hello.png',
type='image/png',
)
file1.putfile(BytesIO('File contents here'))

attachment1 = EventAttachment.objects.create(
event_id=event1.event_id,
group_id=event1.group_id,
project_id=event1.project_id,
file=file1,
name='hello.png',
)

path = '/api/0/projects/{}/{}/events/{}/attachments/{}/?download'.format(
event1.project.organization.slug,
event1.project.slug,
event1.id,
attachment1.id,
)

with self.feature('organizations:event-attachments'):
response = self.client.get(path)

assert response.status_code == 200, response.content
assert response.get('Content-Disposition') == 'attachment; filename="hello.png"'
assert response.get('Content-Length') == six.text_type(file1.size)
assert response.get('Content-Type') == 'application/octet-stream'
assert 'File contents here' == BytesIO(b"".join(response.streaming_content)).getvalue()

0 comments on commit c440bd6

Please sign in to comment.