Browse Source

Merge pull request #3055 from getsentry/audit-log-react

Expand Audit Log features
David Cramer 9 years ago
parent
commit
bf65e18430

+ 35 - 0
src/sentry/api/endpoints/organization_auditlogs.py

@@ -0,0 +1,35 @@
+from __future__ import absolute_import
+
+from sentry.api.bases import OrganizationEndpoint
+from sentry.api.paginator import DateTimePaginator
+from sentry.api.serializers import serialize
+from sentry.models import AuditLogEntry
+
+EVENT_REVERSE_MAP = {
+    v: k
+    for k, v in AuditLogEntry._meta.get_field('event').choices
+}
+
+
+class OrganizationAuditLogsEndpoint(OrganizationEndpoint):
+    def get(self, request, organization):
+        queryset = AuditLogEntry.objects.filter(
+            organization=organization,
+        ).select_related('actor')
+
+        event = request.GET.get('event')
+        if event:
+            try:
+                queryset = queryset.filter(
+                    event=EVENT_REVERSE_MAP[event],
+                )
+            except KeyError:
+                queryset = queryset.none()
+
+        return self.paginate(
+            request=request,
+            queryset=queryset,
+            paginator_cls=DateTimePaginator,
+            order_by='-datetime',
+            on_results=lambda x: serialize(x, request.user),
+        )

+ 33 - 0
src/sentry/api/serializers/models/auditlogentry.py

@@ -0,0 +1,33 @@
+from __future__ import absolute_import
+
+from sentry.api.serializers import Serializer, register, serialize
+from sentry.models import AuditLogEntry
+
+
+@register(AuditLogEntry)
+class AuditLogEntrySerializer(Serializer):
+    def get_attrs(self, item_list, user):
+        # TODO(dcramer); assert on relations
+        actors = {
+            d['id']: d
+            for d in serialize(set(i.actor for i in item_list if i.actor_id), user)
+        }
+
+        return {
+            item: {
+                'actor': actors[str(item.actor_id)] if item.actor_id else {
+                    'name': item.get_actor_name(),
+                },
+            } for item in item_list
+        }
+
+    def serialize(self, obj, attrs, user):
+        return {
+            'id': str(obj.id),
+            'actor': attrs['actor'],
+            'event': obj.get_event_display(),
+            'ipAddress': obj.ip_address,
+            'note': obj.get_note(),
+            'data': obj.data,
+            'dateCreated': obj.datetime,
+        }

+ 4 - 0
src/sentry/api/urls.py

@@ -23,6 +23,7 @@ from .endpoints.internal_stats import InternalStatsEndpoint
 from .endpoints.legacy_project_redirect import LegacyProjectRedirectEndpoint
 from .endpoints.organization_access_request_details import OrganizationAccessRequestDetailsEndpoint
 from .endpoints.organization_activity import OrganizationActivityEndpoint
+from .endpoints.organization_auditlogs import OrganizationAuditLogsEndpoint
 from .endpoints.organization_details import OrganizationDetailsEndpoint
 from .endpoints.organization_shortid import ShortIdLookupEndpoint
 from .endpoints.organization_slugs import SlugsUpdateEndpoint
@@ -122,6 +123,9 @@ urlpatterns = patterns(
     url(r'^organizations/(?P<organization_slug>[^\/]+)/activity/$',
         OrganizationActivityEndpoint.as_view(),
         name='sentry-api-0-organization-activity'),
+    url(r'^organizations/(?P<organization_slug>[^\/]+)/audit-logs/$',
+        OrganizationAuditLogsEndpoint.as_view(),
+        name='sentry-api-0-organization-audit-logs'),
     url(r'^organizations/(?P<organization_slug>[^\/]+)/issues/new/$',
         OrganizationIssuesNewEndpoint.as_view(),
         name='sentry-api-0-organization-issues-new'),

+ 1 - 0
src/sentry/models/auditlogentry.py

@@ -70,6 +70,7 @@ class AuditLogEntry(Model):
     target_object = BoundedPositiveIntegerField(null=True)
     target_user = FlexibleForeignKey('sentry.User', null=True, blank=True,
                                     related_name='audit_targets')
+    # TODO(dcramer): we want to compile this mapping into JSX for the UI
     event = BoundedPositiveIntegerField(choices=(
         # We emulate github a bit with event naming
         (AuditLogEntryEvent.MEMBER_INVITE, 'member.invite'),

+ 1 - 1
src/sentry/static/sentry/app/components/organizations/homeSidebar.jsx

@@ -77,7 +77,7 @@ const HomeSidebar = React.createClass({
                 <li><a href={urlPrefix + '/api-keys/'}>{t('API Keys')}</a></li>
               }
               {access.has('org:write') &&
-                <li><a href={urlPrefix + '/audit-log/'}>{t('Audit Log')}</a></li>
+                <ListLink to={`/organizations/${orgId}/audit-log/`}>{t('Audit Log')}</ListLink>
               }
               {access.has('org:write') &&
                 <ListLink to={`/organizations/${orgId}/rate-limits/`}>{t('Rate Limits')}</ListLink>

+ 4 - 11
src/sentry/static/sentry/app/components/selectInput.jsx

@@ -48,7 +48,9 @@ const SelectInput = React.createClass({
   },
 
   create() {
-    this.select2 = jQuery(this.refs.select).select2();
+    this.select2 = jQuery(this.refs.select).select2({
+      width: 'element'
+    });
     this.select2.on('change', this.onChange);
   },
 
@@ -61,17 +63,8 @@ const SelectInput = React.createClass({
   },
 
   render() {
-    let opts = {
-        ref: 'select',
-        disabled: this.props.disabled,
-        required: this.props.required,
-        multiple: this.props.multiple,
-        placeholder: this.props.placeholder,
-        className: this.props.className,
-        value: this.props.value,
-    };
     return (
-      <select {...opts}>
+      <select ref="select" {...this.props}>
         {this.props.children}
       </select>
     );

+ 2 - 0
src/sentry/static/sentry/app/routes.jsx

@@ -18,6 +18,7 @@ import GroupUserReports from './views/groupUserReports';
 import MyIssuesAssignedToMe from './views/myIssues/assignedToMe';
 import MyIssuesBookmarked from './views/myIssues/bookmarked';
 import MyIssuesViewed from './views/myIssues/viewed';
+import OrganizationAuditLog from './views/organizationAuditLog';
 import OrganizationDashboard from './views/organizationDashboard';
 import OrganizationDetails from './views/organizationDetails';
 import OrganizationRateLimits from './views/organizationRateLimits';
@@ -75,6 +76,7 @@ let routes = (
     <Route path="/:orgId/" component={errorHandler(OrganizationDetails)}>
       <IndexRoute component={errorHandler(OrganizationDashboard)}/>
 
+      <Route path="/organizations/:orgId/audit-log/" component={errorHandler(OrganizationAuditLog)} />
       <Route path="/organizations/:orgId/teams/" component={errorHandler(OrganizationTeams)} />
       <Route path="/organizations/:orgId/all-teams/" component={errorHandler(OrganizationTeams)}>
         <IndexRoute component={errorHandler(AllTeamsList)}/>

+ 205 - 0
src/sentry/static/sentry/app/views/organizationAuditLog.jsx

@@ -0,0 +1,205 @@
+import React from 'react';
+import DocumentTitle from 'react-document-title';
+import {History} from 'react-router';
+
+import ApiMixin from '../mixins/apiMixin';
+import DateTime from '../components/dateTime';
+import Gravatar from '../components/gravatar';
+import LoadingIndicator from '../components/loadingIndicator';
+import LoadingError from '../components/loadingError';
+import OrganizationHomeContainer from '../components/organizations/homeContainer';
+import OrganizationState from '../mixins/organizationState';
+import Pagination from '../components/pagination';
+import SelectInput from '../components/selectInput';
+
+import {t} from '../locale';
+
+const EVENT_TYPES = [
+  'member.invite',
+  'member.add',
+  'member.accept-invite',
+  'member.remove',
+  'member.edit',
+  'member.join-team',
+  'member.leave-team',
+  'team.create',
+  'team.edit',
+  'team.remove',
+  'project.create',
+  'project.edit',
+  'project.remove',
+  'project.set-public',
+  'project.set-private',
+  'org.create',
+  'org.edit',
+  'org.remove',
+  'tagkey.remove',
+  'projectkey.create',
+  'projectkey.edit',
+  'projectkey.remove',
+  'projectkey.enable',
+  'projectkey.disable',
+  'sso.enable',
+  'sso.disable',
+  'sso.edit',
+  'sso-identity.link',
+  'api-key.create',
+  'api-key.edit',
+  'api-key.remove'
+].sort();
+
+
+const OrganizationAuditLog = React.createClass({
+  mixins: [
+    ApiMixin,
+    History,
+    OrganizationState,
+  ],
+
+  getInitialState() {
+    return {
+      loading: true,
+      error: false,
+      entryList: [],
+    };
+  },
+
+  componentWillMount() {
+    this.fetchData();
+  },
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.location.search !== this.props.location.search ||
+        nextProps.params.orgId !== this.props.params.orgId) {
+      this.remountComponent();
+    }
+  },
+
+  remountComponent() {
+    this.setState(this.getInitialState(), this.fetchData);
+  },
+
+  fetchData() {
+    this.setState({
+      loading: true,
+    });
+
+    this.api.request(this.getEndpoint(), {
+      query: this.props.location.query,
+      success: (data, _, jqXHR) => {
+        this.setState({
+          loading: false,
+          error: false,
+          entryList: data,
+          pageLinks: jqXHR.getResponseHeader('Link'),
+        });
+      },
+      error: () => {
+        this.setState({
+          loading: false,
+          error: true,
+        });
+      }
+    });
+  },
+
+  getEndpoint() {
+    return `/organizations/${this.props.params.orgId}/audit-logs/`;
+  },
+
+  getTitle() {
+    let org = this.context.organization;
+    return `${org.name} Audit Log`;
+  },
+
+  onEventSelect(sel) {
+    let value = sel.val();
+    if (this.props.location.query.event === value) {
+      return;
+    }
+    let queryParams = {
+      event: value,
+    };
+    this.history.pushState(null, this.props.location.pathname, queryParams);
+  },
+
+  renderResults() {
+    if (this.state.entryList.length === 0) {
+      return <tr><td colSpan="4">{t('No results found.')}</td></tr>;
+    }
+
+    return this.state.entryList.map((entry) => {
+      return (
+        <tr key={entry.id}>
+          <td className="table-user-info">
+            {entry.actor.email &&
+              <Gravatar user={entry.actor} />
+            }
+            <h5>{entry.actor.name}</h5>
+            {entry.note}
+          </td>
+          <td>{entry.event}</td>
+          <td>{entry.ipAddress}</td>
+          <td>
+            <DateTime date={entry.dateCreated} />
+          </td>
+        </tr>
+      );
+    });
+  },
+
+  render() {
+    let currentEventType = this.props.location.query.event;
+
+    return (
+      <DocumentTitle title={this.getTitle()}>
+        <OrganizationHomeContainer>
+          <h3>{t('Audit Log')}</h3>
+
+          <div className="pull-right">
+            <form className="form-horizontal" style={{marginBottom: 20}}>
+              <div className="control-group">
+                <div className="controls">
+                  <SelectInput name="event" onChange={this.onEventSelect}
+                               value={currentEventType} style={{width: 250}}>
+                    <option key="any" value="">{t('Any')}</option>
+                    {EVENT_TYPES.map((eventType) => {
+                      return <option key={eventType}>{eventType}</option>;
+                    })}
+                  </SelectInput>
+                </div>
+              </div>
+            </form>
+          </div>
+
+          <p>{t('Sentry keeps track of important events within your organization.')}</p>
+
+          <table className="table">
+            <thead>
+              <tr>
+                <th>{t('Member')}</th>
+                <th>{t('Action')}</th>
+                <th>{t('IP')}</th>
+                <th>{t('Time')}</th>
+              </tr>
+            </thead>
+            <tbody>
+              {(this.state.loading ?
+                <tr><td colSpan="4"><LoadingIndicator /></td></tr>
+              : (this.state.error ?
+                <tr><td colSpan="4"><LoadingError onRetry={this.fetchData} /></td></tr>
+              :
+                this.renderResults()
+              ))}
+            </tbody>
+          </table>
+          {this.state.pageLinks &&
+            <Pagination pageLinks={this.state.pageLinks} {...this.props} />
+          }
+        </OrganizationHomeContainer>
+      </DocumentTitle>
+    );
+  },
+});
+
+export default OrganizationAuditLog;

+ 0 - 1
src/sentry/static/sentry/less/includes/select2.less

@@ -11,7 +11,6 @@
   padding: 4px 10px 3px;
   top: -1px;
   box-shadow: 0 1px 1px rgba(0, 0, 0, .05);
-  max-width: 180px;
 }
 
 .select2-container,

+ 1 - 1
src/sentry/static/sentry/less/shared-components.less

@@ -347,7 +347,7 @@ table.table {
       .square(36px);
       position: absolute;
       left: 20px;
-      top: 18px;
+      top: 14px;
     }
   }
 

Some files were not shown because too many files changed in this diff