Browse Source

fix(hybridcloud) Move organization restore to react (#60301)

The organization restore process currently generates URLs like
`acme.sentry.io/restore`. The django view for this URL requires region
resources (Organization), and the internal API client. Instead of making
both of these work across regions we can replace the view with react and
use the existing API from the client.

Moving this view to react helps us reduce the number of django templates
we have and helps with hybrid cloud as we can route API requests more
easily.

Fixes HC-991
Mark Story 1 year ago
parent
commit
eedf9deaef

+ 0 - 22
src/sentry/templates/sentry/restore-organization.html

@@ -1,22 +0,0 @@
-{% extends "sentry/bases/modal.html" %}
-
-{% load i18n %}
-
-{% block title %}{% trans "Restore Organization" %} | {{ block.super }}{% endblock %}
-
-{% block main %}
-  <section class="body">
-    <h3>{% trans "Deletion Scheduled" %}</h3>
-    {% if pending_deletion %}
-    <form class="form-stacked" action="" method="post">
-      {% csrf_token %}
-      <p>The <strong>{{ deleting_organization.name }}</strong> organization is currently scheduled for deletion.</p>
-      <p>{% blocktrans %}Would you like to cancel this process and restore the organization back to the original state{% endblocktrans %}</p>
-      <p><button type="submit" class="btn btn-primary">{% trans "Restore Organization" %}</button></p>
-      <p>{% blocktrans %}Note: Restoration is available until deletion has started. Once it begins, there's no recovering the data that has been removed.{% endblocktrans %}</p>
-    </form>
-    {% else %}
-    <p>{% blocktrans %}Sorry, but this organization is currently in progress of being deleted. No turning back.{% endblocktrans %} :(</p>
-    {% endif %}
-  </section>
-{% endblock %}

+ 0 - 87
src/sentry/web/frontend/restore_organization.py

@@ -1,87 +0,0 @@
-import logging
-
-from django.contrib import messages
-from django.http import HttpRequest, HttpResponse
-from django.urls import reverse
-from django.utils.translation import gettext_lazy as _
-
-from sentry import audit_log
-from sentry.api import client
-from sentry.models.organization import Organization, OrganizationStatus
-from sentry.services.hybrid_cloud.organization import organization_service
-from sentry.services.hybrid_cloud.organization_actions.impl import (
-    unmark_organization_as_pending_deletion_with_outbox_message,
-)
-from sentry.web.frontend.base import ControlSiloOrganizationView
-from sentry.web.helpers import render_to_response
-
-ERR_MESSAGES = {
-    OrganizationStatus.ACTIVE: _("Deletion already canceled."),
-    OrganizationStatus.DELETION_IN_PROGRESS: _("Deletion cannot be canceled, already in progress"),
-}
-
-MSG_RESTORE_SUCCESS = _("Organization restored successfully.")
-
-delete_logger = logging.getLogger("sentry.deletions.ui")
-
-
-class RestoreOrganizationView(ControlSiloOrganizationView):
-    required_scope = "org:admin"
-    sudo_required = True
-
-    def determine_active_organization(self, request: HttpRequest, organization_slug=None) -> None:
-        # A simplified version than what comes from the base
-        # OrganizationView. We need to grab an organization
-        # that is in any state, not just VISIBLE.
-        organization = organization_service.get_organization_by_slug(
-            user_id=request.user.id, slug=organization_slug, only_visible=False
-        )
-        if organization and organization.member:
-            self.active_organization = organization
-        else:
-            self.active_organization = None
-
-    def get(self, request: HttpRequest, organization) -> HttpResponse:
-        if organization.status == OrganizationStatus.ACTIVE:
-            return self.redirect(Organization.get_url(organization.slug))
-
-        context = {
-            # If this were named 'organization', it triggers logic in the base
-            # template to render organization related content, which isn't relevant
-            # here.
-            "deleting_organization": organization,
-            "pending_deletion": organization.status == OrganizationStatus.PENDING_DELETION,
-        }
-
-        return render_to_response("sentry/restore-organization.html", context, self.request)
-
-    def post(self, request: HttpRequest, organization) -> HttpResponse:
-        deletion_statuses = [
-            OrganizationStatus.PENDING_DELETION,
-            OrganizationStatus.DELETION_IN_PROGRESS,
-        ]
-
-        if organization.status not in deletion_statuses:
-            messages.add_message(request, messages.ERROR, ERR_MESSAGES[organization.status])
-            return self.redirect(reverse("sentry"))
-
-        updated = unmark_organization_as_pending_deletion_with_outbox_message(
-            org_id=organization.id
-        )
-
-        if updated:
-            client.put(
-                f"/organizations/{organization.slug}/",
-                data={"cancelDeletion": True},
-                request=request,
-            )
-            messages.add_message(request, messages.SUCCESS, MSG_RESTORE_SUCCESS)
-            if organization.status == OrganizationStatus.PENDING_DELETION:
-                self.create_audit_entry(
-                    request=request,
-                    organization=organization,
-                    target_object=organization.id,
-                    event=audit_log.get_event_id("ORG_RESTORE"),
-                    data=organization.get_audit_log_data(),
-                )
-        return self.redirect(Organization.get_url(organization.slug))

+ 2 - 3
src/sentry/web/urls.py

@@ -43,7 +43,6 @@ from sentry.web.frontend.project_event import ProjectEventRedirect
 from sentry.web.frontend.react_page import GenericReactPageView, ReactPageView
 from sentry.web.frontend.reactivate_account import ReactivateAccountView
 from sentry.web.frontend.release_webhook import ReleaseWebhookView
-from sentry.web.frontend.restore_organization import RestoreOrganizationView
 from sentry.web.frontend.sentryapp_avatar import SentryAppAvatarPhotoView
 from sentry.web.frontend.setup_wizard import SetupWizardView
 from sentry.web.frontend.shared_group_details import SharedGroupDetailsView
@@ -859,7 +858,7 @@ urlpatterns += [
     # Restore organization
     re_path(
         r"^restore/",
-        RestoreOrganizationView.as_view(),
+        generic_react_page_view,
         name="sentry-customer-domain-restore-organization",
     ),
     # Project on-boarding
@@ -1018,7 +1017,7 @@ urlpatterns += [
                 ),
                 re_path(
                     r"^(?P<organization_slug>[\w_-]+)/restore/$",
-                    RestoreOrganizationView.as_view(),
+                    generic_react_page_view,
                     name="sentry-restore-organization",
                 ),
                 re_path(

+ 10 - 0
static/app/routes.tsx

@@ -253,6 +253,16 @@ function buildRoutes() {
         component={withDomainRedirect(make(() => import('sentry/views/disabledMember')))}
         key="org-disabled-member"
       />
+      {usingCustomerDomain && (
+        <Route
+          path="/restore/"
+          component={make(() => import('sentry/views/organizationRestore'))}
+        />
+      )}
+      <Route
+        path="/organizations/:orgId/restore/"
+        component={make(() => import('sentry/views/organizationRestore'))}
+      />
       {usingCustomerDomain && (
         <Route
           path="/join-request/"

+ 85 - 0
static/app/views/organizationRestore/index.spec.tsx

@@ -0,0 +1,85 @@
+import {Organization} from 'sentry-fixture/organization';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import OrganizationRestore from 'sentry/views/organizationRestore';
+
+describe('OrganizationRestore', function () {
+  let mockUpdate, mockGet;
+  const pendingDeleteOrg = Organization({
+    status: {id: 'pending_deletion', name: 'Pending Deletion'},
+  });
+  const deleteInProgressOrg = Organization({
+    status: {id: 'deletion_in_progress', name: 'Deletion in progress'},
+  });
+
+  beforeEach(() => {
+    mockUpdate = MockApiClient.addMockResponse({
+      url: `/organizations/${pendingDeleteOrg.slug}/`,
+      method: 'PUT',
+      status: 200,
+      body: Organization(),
+    });
+  });
+
+  it('loads the current organization', async () => {
+    mockGet = MockApiClient.addMockResponse({
+      url: `/organizations/${pendingDeleteOrg.slug}/`,
+      method: 'GET',
+      status: 200,
+      body: pendingDeleteOrg,
+    });
+    const {routerProps, routerContext} = initializeOrg<{orgId: string}>({
+      organization: pendingDeleteOrg,
+    });
+    render(<OrganizationRestore {...routerProps} />, {context: routerContext});
+
+    const text = await screen.findByText(/currently scheduled for deletion/);
+    expect(mockGet).toHaveBeenCalled();
+    expect(text).toBeInTheDocument();
+    expect(screen.getByTestId('form-submit')).toBeInTheDocument();
+  });
+
+  it('submits update requests', async () => {
+    mockGet = MockApiClient.addMockResponse({
+      url: `/organizations/${pendingDeleteOrg.slug}/`,
+      method: 'GET',
+      status: 200,
+      body: pendingDeleteOrg,
+    });
+
+    const {routerProps, routerContext} = initializeOrg<{orgId: string}>({
+      organization: pendingDeleteOrg,
+    });
+    render(<OrganizationRestore {...routerProps} />, {context: routerContext});
+
+    const button = await screen.findByTestId('form-submit');
+    await userEvent.click(button);
+
+    expect(mockUpdate).toHaveBeenCalled();
+    expect(window.location.assign).toHaveBeenCalledWith(
+      `/organizations/${pendingDeleteOrg.slug}/issues/`
+    );
+  });
+
+  it('shows message and no form during deletion', async () => {
+    mockGet = MockApiClient.addMockResponse({
+      url: `/organizations/${deleteInProgressOrg.slug}/`,
+      method: 'GET',
+      status: 200,
+      body: deleteInProgressOrg,
+    });
+
+    const {routerProps, routerContext} = initializeOrg<{orgId: string}>({
+      organization: deleteInProgressOrg,
+    });
+    render(<OrganizationRestore {...routerProps} />, {context: routerContext});
+
+    const text = await screen.findByText(
+      /organization is currently in progress of being deleted/
+    );
+    expect(text).toBeInTheDocument();
+    expect(screen.queryByTestId('form-submit')).not.toBeInTheDocument();
+  });
+});

+ 120 - 0
static/app/views/organizationRestore/index.tsx

@@ -0,0 +1,120 @@
+import {Fragment} from 'react';
+import {browserHistory, RouteComponentProps} from 'react-router';
+import styled from '@emotion/styled';
+
+import {addSuccessMessage} from 'sentry/actionCreators/indicator';
+import Alert from 'sentry/components/alert';
+import {Button} from 'sentry/components/button';
+import ApiForm from 'sentry/components/forms/apiForm';
+import HiddenField from 'sentry/components/forms/fields/hiddenField';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import NarrowLayout from 'sentry/components/narrowLayout';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Organization} from 'sentry/types';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import {useParams} from 'sentry/utils/useParams';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+
+type Props = RouteComponentProps<{orgId: string}, {}>;
+
+function OrganizationRestore(_props: Props) {
+  const params = useParams<{orgId: string}>();
+  return (
+    <SentryDocumentTitle title={t('Restore Organization')}>
+      <NarrowLayout>
+        <h3>{t('Deletion Scheduled')}</h3>
+        <OrganizationRestoreBody orgSlug={params.orgId} />
+      </NarrowLayout>
+    </SentryDocumentTitle>
+  );
+}
+
+type BodyProps = {
+  orgSlug: string;
+};
+
+function OrganizationRestoreBody({orgSlug}: BodyProps) {
+  const endpoint = `/organizations/${orgSlug}/`;
+  const {isLoading, isError, data} = useApiQuery<Organization>([endpoint], {
+    staleTime: 0,
+  });
+  if (isLoading) {
+    return <LoadingIndicator />;
+  }
+  if (isError) {
+    return (
+      <Alert type="error">{t('There was an error loading your organization.')}</Alert>
+    );
+  }
+  if (data.status.id === 'active') {
+    browserHistory.replace(normalizeUrl(`/organizations/${orgSlug}/issues/`));
+    return null;
+  }
+  if (data.status.id === 'pending_deletion') {
+    return <RestoreForm organization={data} endpoint={endpoint} />;
+  }
+  return (
+    <p>
+      {t(
+        'Sorry, but this organization is currently in progress of being deleted. No turning back.'
+      )}
+    </p>
+  );
+}
+
+type RestoreFormProps = {
+  endpoint: string;
+  organization: Organization;
+};
+
+function RestoreForm({endpoint, organization}: RestoreFormProps) {
+  return (
+    <Fragment>
+      <ApiForm
+        apiEndpoint={endpoint}
+        apiMethod="PUT"
+        submitLabel={t('Restore Organization')}
+        onSubmitSuccess={() => {
+          addSuccessMessage(t('Organization Restored'));
+
+          // Use window.location to ensure page reloads
+          window.location.assign(
+            normalizeUrl(`/organizations/${organization.slug}/issues/`)
+          );
+        }}
+        initialData={{cancelDeletion: 1}}
+        hideFooter
+      >
+        <HiddenField name="cancelDeletion" />
+        <p>
+          {tct('The [name] organization is currently scheduled for deletion.', {
+            name: <strong>{organization.slug}</strong>,
+          })}
+        </p>
+        <p>
+          {t(
+            'Would you like to cancel this process and restore the organization back to the original state?'
+          )}
+        </p>
+        <ButtonWrapper>
+          <Button data-test-id="form-submit" priority="primary" type="submit">
+            {t('Restore Organization')}
+          </Button>
+        </ButtonWrapper>
+      </ApiForm>
+      <p>
+        {t(
+          'Note: Restoration is available until deletion has started. Once it begins, there is no recovering the data that has been removed.'
+        )}
+      </p>
+    </Fragment>
+  );
+}
+
+const ButtonWrapper = styled('div')`
+  margin-bottom: ${space(2)};
+`;
+
+export default OrganizationRestore;

+ 0 - 134
tests/sentry/web/frontend/test_restore_organization.py

@@ -1,134 +0,0 @@
-from django.urls import reverse
-
-from sentry.models.organization import Organization, OrganizationStatus
-from sentry.models.scheduledeletion import RegionScheduledDeletion
-from sentry.services.hybrid_cloud.organization.serial import serialize_rpc_organization
-from sentry.silo import SiloMode
-from sentry.tasks.deletion.scheduled import run_deletion
-from sentry.testutils.cases import PermissionTestCase, TestCase
-from sentry.testutils.silo import assume_test_silo_mode, region_silo_test
-
-
-@region_silo_test(stable=True)
-class RestoreOrganizationPermissionTest(PermissionTestCase):
-    def setUp(self):
-        super().setUp()
-        self.organization = self.create_organization(
-            name="foo", owner=self.user, status=OrganizationStatus.PENDING_DELETION
-        )
-        self.path = reverse("sentry-restore-organization", args=[self.organization.slug])
-
-    def test_teamless_admin_cannot_load(self):
-        self.assert_teamless_admin_cannot_access(self.path)
-
-    def test_team_admin_cannot_load(self):
-        self.assert_team_admin_cannot_access(self.path)
-
-    def test_owner_can_load(self):
-        self.assert_owner_can_access(self.path)
-
-
-@region_silo_test(stable=True)
-class RemoveOrganizationTest(TestCase):
-    def setUp(self):
-        super().setUp()
-
-        self.organization = self.create_organization(
-            name="foo", owner=self.user, status=OrganizationStatus.PENDING_DELETION
-        )
-        self.team = self.create_team(organization=self.organization)
-        self.path = reverse("sentry-restore-organization", args=[self.organization.slug])
-
-        self.login_as(self.user)
-
-    def test_renders_with_context(self):
-        resp = self.client.get(self.path)
-
-        assert resp.status_code == 200
-
-        self.assertTemplateUsed(resp, "sentry/restore-organization.html")
-
-        assert resp.context["deleting_organization"] == serialize_rpc_organization(
-            self.organization
-        )
-        assert resp.context["pending_deletion"] is True
-
-        self.organization.update(status=OrganizationStatus.DELETION_IN_PROGRESS)
-
-        resp = self.client.get(self.path)
-
-        assert resp.status_code == 200
-
-        self.assertTemplateUsed(resp, "sentry/restore-organization.html")
-
-        assert resp.context["deleting_organization"] == serialize_rpc_organization(
-            self.organization
-        )
-        assert resp.context["pending_deletion"] is False
-
-    def test_renders_with_context_customer_domain(self):
-        path = reverse("sentry-customer-domain-restore-organization")
-
-        resp = self.client.get(path, SERVER_NAME=f"{self.organization.slug}.testserver")
-
-        assert resp.status_code == 200
-
-        self.assertTemplateUsed(resp, "sentry/restore-organization.html")
-
-        assert resp.context["deleting_organization"] == serialize_rpc_organization(
-            self.organization
-        )
-        assert resp.context["pending_deletion"] is True
-
-        self.organization.update(status=OrganizationStatus.DELETION_IN_PROGRESS)
-
-        resp = self.client.get(path, SERVER_NAME=f"{self.organization.slug}.testserver")
-
-        assert resp.status_code == 200
-
-        self.assertTemplateUsed(resp, "sentry/restore-organization.html")
-
-        assert resp.context["deleting_organization"] == serialize_rpc_organization(
-            self.organization
-        )
-        assert resp.context["pending_deletion"] is False
-
-    def test_success(self):
-        resp = self.client.post(self.path)
-
-        assert resp.status_code == 302
-
-        org = Organization.objects.get(id=self.organization.id)
-
-        assert org.status == OrganizationStatus.ACTIVE
-
-    def test_too_late_still_restores(self):
-        self.organization.update(status=OrganizationStatus.DELETION_IN_PROGRESS)
-
-        resp = self.client.post(self.path)
-
-        assert resp.status_code == 302
-
-        org = Organization.objects.get(id=self.organization.id)
-
-        assert org.status == OrganizationStatus.ACTIVE
-
-    def test_org_already_deleted(self):
-        assert RegionScheduledDeletion.objects.count() == 0
-
-        org_id = self.organization.id
-        self.organization.update(status=OrganizationStatus.PENDING_DELETION)
-        deletion = RegionScheduledDeletion.schedule(self.organization, days=0)
-        deletion.update(in_progress=True)
-
-        with self.tasks():
-            run_deletion(deletion.id)
-
-        assert Organization.objects.filter(id=org_id).count() == 0
-
-        with assume_test_silo_mode(SiloMode.CONTROL):
-            resp = self.client.post(self.path, follow=True)
-
-        assert resp.status_code == 200
-        assert resp.redirect_chain == [("/auth/login/", 302), ("/organizations/new/", 302)]
-        assert Organization.objects.filter(id=org_id).count() == 0