diff --git a/src/sentry/api/bases/project.py b/src/sentry/api/bases/project.py
index fc8f56e0565e56..a224e949705d6b 100644
--- a/src/sentry/api/bases/project.py
+++ b/src/sentry/api/bases/project.py
@@ -1,8 +1,10 @@
from __future__ import absolute_import
+from rest_framework.response import Response
+
from sentry import roles
from sentry.api.base import Endpoint
-from sentry.api.exceptions import ResourceDoesNotExist, ResourceMoved
+from sentry.api.exceptions import ResourceDoesNotExist, ProjectMoved
from sentry.app import raven
from sentry.auth.superuser import is_active_superuser
from sentry.models import OrganizationMember, Project, ProjectStatus, ProjectRedirect
@@ -121,7 +123,19 @@ def convert_args(self, request, organization_slug, project_slug, *args, **kwargs
redirect_slug=project_slug
)
- raise ResourceMoved(detail={'slug': redirect.project.slug})
+ # get full path so that we keep query strings
+ requested_url = request.get_full_path()
+ new_url = requested_url.replace(
+ 'projects/%s/%s/' %
+ (organization_slug, project_slug), 'projects/%s/%s/' %
+ (organization_slug, redirect.project.slug))
+
+ # Resource was moved/renamed if the requested url is different than the new url
+ if requested_url != new_url:
+ raise ProjectMoved(new_url, redirect.project.slug)
+
+ # otherwise project doesn't exist
+ raise ResourceDoesNotExist
except ProjectRedirect.DoesNotExist:
raise ResourceDoesNotExist
@@ -139,3 +153,13 @@ def convert_args(self, request, organization_slug, project_slug, *args, **kwargs
kwargs['project'] = project
return (args, kwargs)
+
+ def handle_exception(self, request, exc):
+ if isinstance(exc, ProjectMoved):
+ response = Response({
+ 'slug': exc.detail['extra']['slug'],
+ 'detail': exc.detail
+ }, status=exc.status_code)
+ response['Location'] = exc.detail['extra']['url']
+ return response
+ return super(ProjectEndpoint, self).handle_exception(request, exc)
diff --git a/src/sentry/api/exceptions.py b/src/sentry/api/exceptions.py
index 8c057867234312..f07eba258594e0 100644
--- a/src/sentry/api/exceptions.py
+++ b/src/sentry/api/exceptions.py
@@ -9,10 +9,6 @@ class ResourceDoesNotExist(APIException):
status_code = status.HTTP_404_NOT_FOUND
-class ResourceMoved(APIException):
- status_code = status.HTTP_302_FOUND
-
-
class SentryAPIException(APIException):
code = ''
message = ''
@@ -28,6 +24,19 @@ def __init__(self, code=None, message=None, detail=None, **kwargs):
super(SentryAPIException, self).__init__(detail=detail)
+class ProjectMoved(SentryAPIException):
+ status_code = status.HTTP_302_FOUND
+ # code/message currently don't get used
+ code = 'resource-moved'
+ message = 'Resource has been moved'
+
+ def __init__(self, new_url, slug):
+ super(ProjectMoved, self).__init__(
+ url=new_url,
+ slug=slug,
+ )
+
+
class SsoRequired(SentryAPIException):
status_code = status.HTTP_401_UNAUTHORIZED
code = 'sso-required'
diff --git a/src/sentry/static/sentry/app/__mocks__/api.jsx b/src/sentry/static/sentry/app/__mocks__/api.jsx
index a67883b4853223..db731cc5272404 100644
--- a/src/sentry/static/sentry/app/__mocks__/api.jsx
+++ b/src/sentry/static/sentry/app/__mocks__/api.jsx
@@ -62,7 +62,10 @@ class Client {
}
wrapCallback(id, error) {
- return (...args) => respond(Client.mockAsync, error, ...args);
+ return (...args) => {
+ if (this.hasProjectBeenRenamed(...args)) return;
+ respond(Client.mockAsync, error, ...args);
+ };
}
requestPromise(url, options) {
@@ -125,5 +128,7 @@ Client.prototype.uniqueId = RealClient.Client.prototype.uniqueId;
Client.prototype.bulkUpdate = RealClient.Client.prototype.bulkUpdate;
Client.prototype._chain = RealClient.Client.prototype._chain;
Client.prototype._wrapRequest = RealClient.Client.prototype._wrapRequest;
+Client.prototype.hasProjectBeenRenamed =
+ RealClient.Client.prototype.hasProjectBeenRenamed;
export {Client};
diff --git a/src/sentry/static/sentry/app/actionCreators/modal.jsx b/src/sentry/static/sentry/app/actionCreators/modal.jsx
index 83ea740e7cbe63..a7ff15720a2ebb 100644
--- a/src/sentry/static/sentry/app/actionCreators/modal.jsx
+++ b/src/sentry/static/sentry/app/actionCreators/modal.jsx
@@ -100,3 +100,11 @@ export function openIntegrationDetails(options = {}) {
});
});
}
+
+export function redirectToProject(newProjectSlug) {
+ import(/* webpackChunkName: "RedirectToProjectModal" */ 'app/components/modals/redirectToProject')
+ .then(mod => mod.default)
+ .then(Modal => {
+ openModal(deps => , {});
+ });
+}
diff --git a/src/sentry/static/sentry/app/api.jsx b/src/sentry/static/sentry/app/api.jsx
index d36dff27889b44..1f3c717383f9af 100644
--- a/src/sentry/static/sentry/app/api.jsx
+++ b/src/sentry/static/sentry/app/api.jsx
@@ -2,7 +2,12 @@ import $ from 'jquery';
import {isUndefined, isNil} from 'lodash';
import idx from 'idx';
-import {openSudo} from 'app/actionCreators/modal';
+import {
+ PROJECT_MOVED,
+ SUDO_REQUIRED,
+ SUPERUSER_REQUIRED,
+} from 'app/constants/apiErrorCodes';
+import {openSudo, redirectToProject} from 'app/actionCreators/modal';
import GroupActions from 'app/actions/groupActions';
export class Request {
@@ -53,6 +58,23 @@ export class Client {
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
}
+ /**
+ * Check if the API response says project has been renamed.
+ * If so, redirect user to new project slug
+ */
+ hasProjectBeenRenamed(response) {
+ let code = response && idx(response, _ => _.responseJSON.detail.code);
+
+ // XXX(billy): This actually will never happen because we can't intercept the 302
+ // jQuery ajax will follow the redirect by default...
+ if (code !== PROJECT_MOVED) return false;
+
+ let slug = response && idx(response, _ => _.responseJSON.detail.extra.slug);
+
+ redirectToProject(slug);
+ return true;
+ }
+
wrapCallback(id, func, cleanup) {
/*eslint consistent-return:0*/
if (isUndefined(func)) {
@@ -65,6 +87,11 @@ export class Client {
delete this.activeRequests[id];
}
if (req && req.alive) {
+ // Check if API response is a 302 -- means project slug was renamed and user
+ // needs to be redirected
+ if (this.hasProjectBeenRenamed(...args)) return;
+
+ // Call success callback
return func.apply(req, args);
}
};
@@ -81,12 +108,12 @@ export class Client {
handleRequestError({id, path, requestOptions}, response, ...responseArgs) {
let code = response && idx(response, _ => _.responseJSON.detail.code);
- let isSudoRequired = code === 'sudo-required' || code === 'superuser-required';
+ let isSudoRequired = code === SUDO_REQUIRED || code === SUPERUSER_REQUIRED;
if (isSudoRequired) {
openSudo({
- superuser: code === 'superuser-required',
- sudo: code === 'sudo-required',
+ superuser: code === SUPERUSER_REQUIRED,
+ sudo: code === SUDO_REQUIRED,
retryRequest: () => {
return this.requestPromise(path, requestOptions)
.then((...args) => {
diff --git a/src/sentry/static/sentry/app/components/modals/redirectToProject.jsx b/src/sentry/static/sentry/app/components/modals/redirectToProject.jsx
new file mode 100644
index 00000000000000..dea89a285d3051
--- /dev/null
+++ b/src/sentry/static/sentry/app/components/modals/redirectToProject.jsx
@@ -0,0 +1,94 @@
+import {Flex} from 'grid-emotion';
+import {withRouter} from 'react-router';
+import PropTypes from 'prop-types';
+import React from 'react';
+import styled from 'react-emotion';
+
+import {t, tct} from 'app/locale';
+import Button from 'app/components/buttons/button';
+import Text from 'app/components/text';
+import recreateRoute from 'app/utils/recreateRoute';
+
+class RedirectToProjectModal extends React.Component {
+ static propTypes = {
+ /**
+ * New slug to redirect to
+ */
+ slug: PropTypes.string.isRequired,
+
+ Header: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired,
+ Body: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired,
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ timer: 5,
+ };
+ }
+
+ componentDidMount() {
+ setInterval(() => {
+ if (this.state.timer <= 1) {
+ window.location.assign(this.getNewPath());
+ return;
+ }
+
+ this.setState(state => ({
+ timer: state.timer - 1,
+ }));
+ }, 1000);
+ }
+
+ getNewPath() {
+ let {params, slug} = this.props;
+
+ return recreateRoute('', {
+ ...this.props,
+ params: {
+ ...params,
+ projectId: slug,
+ },
+ });
+ }
+
+ render() {
+ let {slug, Header, Body} = this.props;
+ return (
+
+ {t('Redirecting to New Project...')}
+
+
+
+
+
{t('The project slug has been changed.')}
+
+
+ {tct(
+ 'You will be redirected to the new project [project] in [timer] seconds...',
+ {
+ project: {slug},
+ timer: `${this.state.timer}`,
+ }
+ )}
+