Repository: readthedocs/readthedocs.org

Issue:
Triggering a build manually redirects to build listing
Currently, if you trigger a build manually from the project details page or from the build listing page the user is redirected to the build listing page. I think it would be better if the user is redirected to the build details page since the build object is already created.

In the details page the user can see that the build was triggered easily and maybe wait for completion while checking the commands.

> If we agree on making this change, we should mark this issue as `Good First Issue`


Discussion:
Yeah, this bugs me a lot. It should be a small change.
I'd like to take a stab at this issue, if no one would mind... Any pointers as to which part of the codebase needs the relevant changes here? :)
@pradyunsg here is the current redirect https://github.com/rtfd/readthedocs.org/blob/dde104daf23f580887e79d4fae91cf9577fbbb76/readthedocs/builds/views.py#L58-L58, we need to change that.

Resolving patch:
diff --git a/readthedocs/builds/views.py b/readthedocs/builds/views.py
--- a/readthedocs/builds/views.py
+++ b/readthedocs/builds/views.py
@@ -1,26 +1,33 @@
+# -*- coding: utf-8 -*-
+
 """Views for builds app."""
 
-from __future__ import absolute_import
-from builtins import object
+from __future__ import (
+    absolute_import,
+    division,
+    print_function,
+    unicode_literals,
+)
+
 import logging
 
-from django.shortcuts import get_object_or_404
-from django.views.generic import ListView, DetailView
+from builtins import object
+from django.contrib.auth.decorators import login_required
+from django.core.urlresolvers import reverse
 from django.http import (
     HttpResponseForbidden,
     HttpResponsePermanentRedirect,
     HttpResponseRedirect,
 )
-from django.contrib.auth.decorators import login_required
-from readthedocs.core.permissions import AdminPermission
-from django.core.urlresolvers import reverse
+from django.shortcuts import get_object_or_404
 from django.utils.decorators import method_decorator
+from django.views.generic import DetailView, ListView
 
 from readthedocs.builds.models import Build, Version
+from readthedocs.core.permissions import AdminPermission
 from readthedocs.core.utils import trigger_build
 from readthedocs.projects.models import Project
 
-
 log = logging.getLogger(__name__)
 
 
@@ -31,9 +38,11 @@ def get_queryset(self):
         self.project_slug = self.kwargs.get('project_slug', None)
         self.project = get_object_or_404(
             Project.objects.protected(self.request.user),
-            slug=self.project_slug
+            slug=self.project_slug,
+        )
+        queryset = Build.objects.public(
+            user=self.request.user, project=self.project
         )
-        queryset = Build.objects.public(user=self.request.user, project=self.project)
 
         return queryset
 
@@ -54,8 +63,10 @@ def post(self, request, project_slug):
             slug=version_slug,
         )
 
-        trigger_build(project=project, version=version)
-        return HttpResponseRedirect(reverse('builds_project_list', args=[project.slug]))
+        _, build = trigger_build(project=project, version=version)
+        return HttpResponseRedirect(
+            reverse('builds_detail', args=[project.slug, build.pk]),
+        )
 
 
 class BuildList(BuildBase, BuildTriggerMixin, ListView):
@@ -63,11 +74,14 @@ class BuildList(BuildBase, BuildTriggerMixin, ListView):
     def get_context_data(self, **kwargs):
         context = super(BuildList, self).get_context_data(**kwargs)
 
-        active_builds = self.get_queryset().exclude(state="finished").values('id')
+        active_builds = self.get_queryset().exclude(state='finished'
+                                                    ).values('id')
 
         context['project'] = self.project
         context['active_builds'] = active_builds
-        context['versions'] = Version.objects.public(user=self.request.user, project=self.project)
+        context['versions'] = Version.objects.public(
+            user=self.request.user, project=self.project
+        )
         context['build_qs'] = self.get_queryset()
 
         return context
@@ -84,9 +98,14 @@ def get_context_data(self, **kwargs):
 
 # Old build view redirects
 
+
 def builds_redirect_list(request, project_slug):  # pylint: disable=unused-argument
-    return HttpResponsePermanentRedirect(reverse('builds_project_list', args=[project_slug]))
+    return HttpResponsePermanentRedirect(
+        reverse('builds_project_list', args=[project_slug])
+    )
 
 
 def builds_redirect_detail(request, project_slug, pk):  # pylint: disable=unused-argument
-    return HttpResponsePermanentRedirect(reverse('builds_detail', args=[project_slug, pk]))
+    return HttpResponsePermanentRedirect(
+        reverse('builds_detail', args=[project_slug, pk])
+    )
diff --git a/readthedocs/core/utils/__init__.py b/readthedocs/core/utils/__init__.py
--- a/readthedocs/core/utils/__init__.py
+++ b/readthedocs/core/utils/__init__.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+
 """Common utilty functions."""
 
 from __future__ import absolute_import
@@ -14,13 +15,11 @@
 from django.utils.functional import allow_lazy
 from django.utils.safestring import SafeText, mark_safe
 from django.utils.text import slugify as slugify_base
-from future.backports.urllib.parse import urlparse
 from celery import group, chord
 
 from readthedocs.builds.constants import LATEST, BUILD_STATE_TRIGGERED
 from readthedocs.doc_builder.constants import DOCKER_LIMITS
 
-
 log = logging.getLogger(__name__)
 
 SYNC_USER = getattr(settings, 'SYNC_USER', getpass.getuser())
@@ -40,9 +39,9 @@ def broadcast(type, task, args, kwargs=None, callback=None):  # pylint: disable=
         kwargs = {}
     default_queue = getattr(settings, 'CELERY_DEFAULT_QUEUE', 'celery')
     if type in ['web', 'app']:
-        servers = getattr(settings, "MULTIPLE_APP_SERVERS", [default_queue])
+        servers = getattr(settings, 'MULTIPLE_APP_SERVERS', [default_queue])
     elif type in ['build']:
-        servers = getattr(settings, "MULTIPLE_BUILD_SERVERS", [default_queue])
+        servers = getattr(settings, 'MULTIPLE_BUILD_SERVERS', [default_queue])
 
     tasks = []
     for server in servers:
@@ -71,7 +70,12 @@ def cname_to_slug(host):
 
 
 def prepare_build(
-        project, version=None, record=True, force=False, immutable=True):
+        project,
+        version=None,
+        record=True,
+        force=False,
+        immutable=True,
+):
     """
     Prepare a build in a Celery task for project and version.
 
@@ -132,11 +136,14 @@ def prepare_build(
     options['soft_time_limit'] = time_limit
     options['time_limit'] = int(time_limit * 1.2)
 
-    return update_docs_task.signature(
-        args=(project.pk,),
-        kwargs=kwargs,
-        options=options,
-        immutable=True,
+    return (
+        update_docs_task.signature(
+            args=(project.pk,),
+            kwargs=kwargs,
+            options=options,
+            immutable=True,
+        ),
+        build,
     )
 
 
@@ -151,9 +158,9 @@ def trigger_build(project, version=None, record=True, force=False):
     :param version: version of the project to be built. Default: ``latest``
     :param record: whether or not record the build in a new Build object
     :param force: build the HTML documentation even if the files haven't changed
-    :returns: Celery AsyncResult promise
+    :returns: A tuple (Celery AsyncResult promise, Task Signature from ``prepare_build``)
     """
-    update_docs_task = prepare_build(
+    update_docs_task, build = prepare_build(
         project,
         version,
         record,
@@ -165,11 +172,13 @@ def trigger_build(project, version=None, record=True, force=False):
         # Current project is skipped
         return None
 
-    return update_docs_task.apply_async()
+    return (update_docs_task.apply_async(), build)
 
 
-def send_email(recipient, subject, template, template_html, context=None,
-               request=None, from_email=None, **kwargs):  # pylint: disable=unused-argument
+def send_email(
+        recipient, subject, template, template_html, context=None, request=None,
+        from_email=None, **kwargs
+):  # pylint: disable=unused-argument
     """
     Alter context passed in and call email send task.
 
@@ -183,10 +192,14 @@ def send_email(recipient, subject, template, template_html, context=None,
     if context is None:
         context = {}
     context['uri'] = '{scheme}://{host}'.format(
-        scheme='https', host=settings.PRODUCTION_DOMAIN)
-    send_email_task.delay(recipient=recipient, subject=subject, template=template,
-                          template_html=template_html, context=context, from_email=from_email,
-                          **kwargs)
+        scheme='https',
+        host=settings.PRODUCTION_DOMAIN,
+    )
+    send_email_task.delay(
+        recipient=recipient, subject=subject, template=template,
+        template_html=template_html, context=context, from_email=from_email,
+        **kwargs
+    )
 
 
 def slugify(value, *args, **kwargs):
diff --git a/readthedocs/projects/views/private.py b/readthedocs/projects/views/private.py
--- a/readthedocs/projects/views/private.py
+++ b/readthedocs/projects/views/private.py
@@ -1,8 +1,13 @@
 # -*- coding: utf-8 -*-
+
 """Project views for authenticated users."""
 
 from __future__ import (
-    absolute_import, division, print_function, unicode_literals)
+    absolute_import,
+    division,
+    print_function,
+    unicode_literals,
+)
 
 import logging
 
@@ -13,8 +18,11 @@
 from django.contrib.auth.models import User
 from django.core.urlresolvers import reverse
 from django.http import (
-    Http404, HttpResponseBadRequest, HttpResponseNotAllowed,
-    HttpResponseRedirect)
+    Http404,
+    HttpResponseBadRequest,
+    HttpResponseNotAllowed,
+    HttpResponseRedirect,
+)
 from django.middleware.csrf import get_token
 from django.shortcuts import get_object_or_404, render
 from django.utils.safestring import mark_safe
@@ -22,22 +30,39 @@
 from django.views.generic import ListView, TemplateView, View
 from formtools.wizard.views import SessionWizardView
 from vanilla import CreateView, DeleteView, DetailView, GenericView, UpdateView
+
 from readthedocs.builds.forms import VersionForm
 from readthedocs.builds.models import Version
 from readthedocs.core.mixins import ListViewWithForm, LoginRequiredMixin
-from readthedocs.core.utils import broadcast, trigger_build, prepare_build
+from readthedocs.core.utils import broadcast, prepare_build, trigger_build
 from readthedocs.integrations.models import HttpExchange, Integration
 from readthedocs.oauth.services import registry
-from readthedocs.oauth.utils import update_webhook
 from readthedocs.oauth.tasks import attach_webhook
+from readthedocs.oauth.utils import update_webhook
 from readthedocs.projects import tasks
 from readthedocs.projects.forms import (
-    DomainForm, EmailHookForm, IntegrationForm, ProjectAdvancedForm,
-    ProjectAdvertisingForm, ProjectBasicsForm, ProjectExtraForm,
-    ProjectRelationshipForm, RedirectForm, TranslationForm, UpdateProjectForm,
-    UserForm, WebHookForm, build_versions_form)
+    DomainForm,
+    EmailHookForm,
+    IntegrationForm,
+    ProjectAdvancedForm,
+    ProjectAdvertisingForm,
+    ProjectBasicsForm,
+    ProjectExtraForm,
+    ProjectRelationshipForm,
+    RedirectForm,
+    TranslationForm,
+    UpdateProjectForm,
+    UserForm,
+    WebHookForm,
+    build_versions_form,
+)
 from readthedocs.projects.models import (
-    Domain, EmailHook, Project, ProjectRelationship, WebHook)
+    Domain,
+    EmailHook,
+    Project,
+    ProjectRelationship,
+    WebHook,
+)
 from readthedocs.projects.signals import project_import
 from readthedocs.projects.views.base import ProjectAdminMixin, ProjectSpamMixin
 
@@ -119,7 +144,9 @@ def project_versions(request, project_slug):
     like to have built.
     """
     project = get_object_or_404(
-        Project.objects.for_admin_user(request.user), slug=project_slug)
+        Project.objects.for_admin_user(request.user),
+        slug=project_slug,
+    )
 
     if not project.is_imported:
         raise Http404
@@ -135,19 +162,27 @@ def project_versions(request, project_slug):
         return HttpResponseRedirect(project_dashboard)
 
     return render(
-        request, 'projects/project_versions.html',
-        {'form': form, 'project': project})
+        request,
+        'projects/project_versions.html',
+        {'form': form, 'project': project},
+    )
 
 
 @login_required
 def project_version_detail(request, project_slug, version_slug):
     """Project version detail page."""
     project = get_object_or_404(
-        Project.objects.for_admin_user(request.user), slug=project_slug)
+        Project.objects.for_admin_user(request.user),
+        slug=project_slug,
+    )
     version = get_object_or_404(
         Version.objects.public(
-            user=request.user, project=project, only_active=False),
-        slug=version_slug)
+            user=request.user,
+            project=project,
+            only_active=False,
+        ),
+        slug=version_slug,
+    )
 
     form = VersionForm(request.POST or None, instance=version)
 
@@ -157,15 +192,20 @@ def project_version_detail(request, project_slug, version_slug):
             if 'active' in form.changed_data and version.active is False:
                 log.info('Removing files for version %s', version.slug)
                 broadcast(
-                    type='app', task=tasks.clear_artifacts, args=[version.get_artifact_paths()])
+                    type='app',
+                    task=tasks.clear_artifacts,
+                    args=[version.get_artifact_paths()],
+                )
                 version.built = False
                 version.save()
         url = reverse('project_version_list', args=[project.slug])
         return HttpResponseRedirect(url)
 
     return render(
-        request, 'projects/project_version_detail.html',
-        {'form': form, 'project': project, 'version': version})
+        request,
+        'projects/project_version_detail.html',
+        {'form': form, 'project': project, 'version': version},
+    )
 
 
 @login_required
@@ -177,7 +217,9 @@ def project_delete(request, project_slug):
     confirmation of delete.
     """
     project = get_object_or_404(
-        Project.objects.for_admin_user(request.user), slug=project_slug)
+        Project.objects.for_admin_user(request.user),
+        slug=project_slug,
+    )
 
     if request.method == 'POST':
         broadcast(type='app', task=tasks.remove_dir, args=[project.doc_path])
@@ -240,11 +282,12 @@ def done(self, form_list, **kwargs):
 
         self.trigger_initial_build(project)
         return HttpResponseRedirect(
-            reverse('projects_detail', args=[project.slug]))
+            reverse('projects_detail', args=[project.slug]),
+        )
 
     def trigger_initial_build(self, project):
         """Trigger initial build."""
-        update_docs = prepare_build(project)
+        update_docs, build = prepare_build(project)
         task_promise = chain(
             attach_webhook.si(project.pk, self.request.user.pk),
             update_docs,
@@ -275,10 +318,13 @@ def get(self, request, *args, **kwargs):
 
         data = self.get_form_data()
         project = Project.objects.for_admin_user(
-            request.user).filter(repo=data['repo']).first()
+            request.user,
+        ).filter(repo=data['repo']).first()
         if project is not None:
             messages.success(
-                request, _('The demo project is already imported!'))
+                request,
+                _('The demo project is already imported!'),
+            )
         else:
             kwargs = self.get_form_kwargs()
             form = self.form_class(data=data, **kwargs)
@@ -287,7 +333,9 @@ def get(self, request, *args, **kwargs):
                 project.save()
                 trigger_build(project)
                 messages.success(
-                    request, _('Your demo project is currently being imported'))
+                    request,
+                    _('Your demo project is currently being imported'),
+                )
             else:
                 messages.error(
                     request,
@@ -295,14 +343,15 @@ def get(self, request, *args, **kwargs):
                 )
                 return HttpResponseRedirect(reverse('projects_dashboard'))
         return HttpResponseRedirect(
-            reverse('projects_detail', args=[project.slug]))
+            reverse('projects_detail', args=[project.slug]),
+        )
 
     def get_form_data(self):
         """Get form data to post to import form."""
         return {
             'name': '{0}-demo'.format(self.request.user.username),
             'repo_type': 'git',
-            'repo': 'https://github.com/readthedocs/template.git'
+            'repo': 'https://github.com/readthedocs/template.git',
         }
 
     def get_form_kwargs(self):
@@ -336,7 +385,8 @@ def get(self, request, *args, **kwargs):
             .exclude(
                 provider__in=[
                     service.adapter.provider_id for service in registry
-                ])
+                ],
+            )
         )  # yapf: disable
         for account in deprecated_accounts:
             provider_account = account.get_provider_account()
@@ -346,10 +396,12 @@ def get(self, request, *args, **kwargs):
                     _(
                         'There is a problem with your {service} account, '
                         'try reconnecting your account on your '
-                        '<a href="{url}">connected services page</a>.').format(
-                            service=provider_account.get_brand()['name'],
-                            url=reverse('socialaccount_connections'))
-                ))  # yapf: disable
+                        '<a href="{url}">connected services page</a>.',
+                    ).format(
+                        service=provider_account.get_brand()['name'],
+                        url=reverse('socialaccount_connections'),
+                    )
+                )),  # yapf: disable
             )
         return super(ImportView, self).get(request, *args, **kwargs)
 
@@ -368,7 +420,8 @@ def get_context_data(self, **kwargs):
         context = super(ImportView, self).get_context_data(**kwargs)
         context['view_csrf_token'] = get_token(self.request)
         context['has_connected_accounts'] = SocialAccount.objects.filter(
-            user=self.request.user).exists()
+            user=self.request.user,
+        ).exists()
         return context
 
 
@@ -385,8 +438,10 @@ def get_queryset(self):
 
     def get_form(self, data=None, files=None, **kwargs):
         kwargs['user'] = self.request.user
-        return super(ProjectRelationshipMixin,
-                     self).get_form(data, files, **kwargs)
+        return super(
+            ProjectRelationshipMixin,
+            self,
+        ).get_form(data, files, **kwargs)
 
     def form_valid(self, form):
         broadcast(
@@ -426,7 +481,9 @@ def get(self, request, *args, **kwargs):
 def project_users(request, project_slug):
     """Project users view and form view."""
     project = get_object_or_404(
-        Project.objects.for_admin_user(request.user), slug=project_slug)
+        Project.objects.for_admin_user(request.user),
+        slug=project_slug,
+    )
 
     form = UserForm(data=request.POST or None, project=project)
 
@@ -449,9 +506,13 @@ def project_users_delete(request, project_slug):
     if request.method != 'POST':
         return HttpResponseNotAllowed('Only POST is allowed')
     project = get_object_or_404(
-        Project.objects.for_admin_user(request.user), slug=project_slug)
+        Project.objects.for_admin_user(request.user),
+        slug=project_slug,
+    )
     user = get_object_or_404(
-        User.objects.all(), username=request.POST.get('username'))
+        User.objects.all(),
+        username=request.POST.get('username'),
+    )
     if user == request.user:
         raise Http404
     project.users.remove(user)
@@ -463,7 +524,9 @@ def project_users_delete(request, project_slug):
 def project_notifications(request, project_slug):
     """Project notification view and form view."""
     project = get_object_or_404(
-        Project.objects.for_admin_user(request.user), slug=project_slug)
+        Project.objects.for_admin_user(request.user),
+        slug=project_slug,
+    )
 
     email_form = EmailHookForm(data=request.POST or None, project=project)
     webhook_form = WebHookForm(data=request.POST or None, project=project)
@@ -501,14 +564,18 @@ def project_notifications_delete(request, project_slug):
     if request.method != 'POST':
         return HttpResponseNotAllowed('Only POST is allowed')
     project = get_object_or_404(
-        Project.objects.for_admin_user(request.user), slug=project_slug)
+        Project.objects.for_admin_user(request.user),
+        slug=project_slug,
+    )
     try:
         project.emailhook_notifications.get(
-            email=request.POST.get('email')).delete()
+            email=request.POST.get('email'),
+        ).delete()
     except EmailHook.DoesNotExist:
         try:
             project.webhook_notifications.get(
-                url=request.POST.get('email')).delete()
+                url=request.POST.get('email'),
+            ).delete()
         except WebHook.DoesNotExist:
             raise Http404
     project_dashboard = reverse('projects_notifications', args=[project.slug])
@@ -519,7 +586,9 @@ def project_notifications_delete(request, project_slug):
 def project_translations(request, project_slug):
     """Project translations view and form view."""
     project = get_object_or_404(
-        Project.objects.for_admin_user(request.user), slug=project_slug)
+        Project.objects.for_admin_user(request.user),
+        slug=project_slug,
+    )
     form = TranslationForm(
         data=request.POST or None,
         parent=project,
@@ -566,7 +635,9 @@ def project_translations_delete(request, project_slug, child_slug):
 def project_redirects(request, project_slug):
     """Project redirects view and form view."""
     project = get_object_or_404(
-        Project.objects.for_admin_user(request.user), slug=project_slug)
+        Project.objects.for_admin_user(request.user),
+        slug=project_slug,
+    )
 
     form = RedirectForm(data=request.POST or None, project=project)
 
@@ -578,8 +649,10 @@ def project_redirects(request, project_slug):
     redirects = project.redirects.all()
 
     return render(
-        request, 'projects/project_redirects.html',
-        {'form': form, 'project': project, 'redirects': redirects})
+        request,
+        'projects/project_redirects.html',
+        {'form': form, 'project': project, 'redirects': redirects},
+    )
 
 
 @login_required
@@ -588,15 +661,20 @@ def project_redirects_delete(request, project_slug):
     if request.method != 'POST':
         return HttpResponseNotAllowed('Only POST is allowed')
     project = get_object_or_404(
-        Project.objects.for_admin_user(request.user), slug=project_slug)
+        Project.objects.for_admin_user(request.user),
+        slug=project_slug,
+    )
     redirect = get_object_or_404(
-        project.redirects, pk=request.POST.get('id_pk'))
+        project.redirects,
+        pk=request.POST.get('id_pk'),
+    )
     if redirect.project == project:
         redirect.delete()
     else:
         raise Http404
     return HttpResponseRedirect(
-        reverse('projects_redirects', args=[project.slug]))
+        reverse('projects_redirects', args=[project.slug]),
+    )
 
 
 @login_required
@@ -607,21 +685,33 @@ def project_version_delete_html(request, project_slug, version_slug):
     This marks a version as not built
     """
     project = get_object_or_404(
-        Project.objects.for_admin_user(request.user), slug=project_slug)
+        Project.objects.for_admin_user(request.user),
+        slug=project_slug,
+    )
     version = get_object_or_404(
         Version.objects.public(
-            user=request.user, project=project, only_active=False),
-        slug=version_slug)
+            user=request.user,
+            project=project,
+            only_active=False,
+        ),
+        slug=version_slug,
+    )
 
     if not version.active:
         version.built = False
         version.save()
-        broadcast(type='app', task=tasks.clear_artifacts, args=[version.get_artifact_paths()])
+        broadcast(
+            type='app',
+            task=tasks.clear_artifacts,
+            args=[version.get_artifact_paths()],
+        )
     else:
         return HttpResponseBadRequest(
-            "Can't delete HTML for an active version.")
+            "Can't delete HTML for an active version.",
+        )
     return HttpResponseRedirect(
-        reverse('project_version_list', kwargs={'project_slug': project_slug}))
+        reverse('project_version_list', kwargs={'project_slug': project_slug}),
+    )
 
 
 class DomainMixin(ProjectAdminMixin, PrivateViewMixin):
@@ -719,7 +809,8 @@ def get_template_names(self):
         suffix = self.SUFFIX_MAP.get(integration_type, integration_type)
         return (
             'projects/integration_{0}{1}.html'
-            .format(suffix, self.template_name_suffix))
+            .format(suffix, self.template_name_suffix)
+        )
 
 
 class IntegrationDelete(IntegrationMixin, DeleteView):


Test patch:
diff --git a/readthedocs/rtd_tests/tests/test_views.py b/readthedocs/rtd_tests/tests/test_views.py
--- a/readthedocs/rtd_tests/tests/test_views.py
+++ b/readthedocs/rtd_tests/tests/test_views.py
@@ -1,19 +1,26 @@
-from __future__ import absolute_import
+# -*- coding: utf-8 -*-
+from __future__ import (
+    absolute_import,
+    division,
+    print_function,
+    unicode_literals,
+)
+
+import mock
 from django.contrib.auth.models import User
 from django.core.urlresolvers import reverse
 from django.test import TestCase
 from django.utils.six.moves.urllib.parse import urlsplit
-from django_dynamic_fixture import get
-from django_dynamic_fixture import new
+from django_dynamic_fixture import get, new
 
 from readthedocs.builds.constants import LATEST
+from readthedocs.builds.models import Build
 from readthedocs.core.permissions import AdminPermission
-from readthedocs.projects.models import ImportedFile
-from readthedocs.projects.models import Project
 from readthedocs.projects.forms import UpdateProjectForm
-
+from readthedocs.projects.models import ImportedFile, Project
 
 class Testmaker(TestCase):
+
     def setUp(self):
         self.eric = User(username='eric')
         self.eric.set_password('test')
@@ -27,21 +34,24 @@ def test_imported_docs(self):
         self.assertEqual(r.status_code, 200)
         r = self.client.get('/dashboard/import/manual/', {})
         self.assertEqual(r.status_code, 200)
-        form = UpdateProjectForm(data={
-            'name': 'Django Kong',
-            'repo': 'https://github.com/ericholscher/django-kong',
-            'repo_type': 'git',
-            'description': 'OOHHH AH AH AH KONG SMASH',
-            'language': 'en',
-            'default_branch': '',
-            'project_url': 'http://django-kong.rtfd.org',
-            'default_version': LATEST,
-            'privacy_level': 'public',
-            'version_privacy_level': 'public',
-            'python_interpreter': 'python',
-            'documentation_type': 'sphinx',
-            'csrfmiddlewaretoken': '34af7c8a5ba84b84564403a280d9a9be',
-        }, user=user)
+        form = UpdateProjectForm(
+            data={
+                'name': 'Django Kong',
+                'repo': 'https://github.com/ericholscher/django-kong',
+                'repo_type': 'git',
+                'description': 'OOHHH AH AH AH KONG SMASH',
+                'language': 'en',
+                'default_branch': '',
+                'project_url': 'http://django-kong.rtfd.org',
+                'default_version': LATEST,
+                'privacy_level': 'public',
+                'version_privacy_level': 'public',
+                'python_interpreter': 'python',
+                'documentation_type': 'sphinx',
+                'csrfmiddlewaretoken': '34af7c8a5ba84b84564403a280d9a9be',
+            },
+            user=user,
+        )
         _ = form.save()
         _ = Project.objects.get(slug='django-kong')
 
@@ -111,11 +121,13 @@ def test_project_delete(self):
     def test_subprojects_delete(self):
         # This URL doesn't exist anymore, 404
         response = self.client.get(
-            '/dashboard/pip/subprojects/delete/a-subproject/')
+            '/dashboard/pip/subprojects/delete/a-subproject/',
+        )
         self.assertEqual(response.status_code, 404)
         # New URL
         response = self.client.get(
-            '/dashboard/pip/subprojects/a-subproject/delete/')
+            '/dashboard/pip/subprojects/a-subproject/delete/',
+        )
         self.assertRedirectToLogin(response)
 
     def test_subprojects(self):
@@ -143,7 +155,9 @@ def test_project_translations(self):
         self.assertRedirectToLogin(response)
 
     def test_project_translations_delete(self):
-        response = self.client.get('/dashboard/pip/translations/delete/a-translation/')
+        response = self.client.get(
+            '/dashboard/pip/translations/delete/a-translation/'
+        )
         self.assertRedirectToLogin(response)
 
     def test_project_redirects(self):
@@ -168,7 +182,8 @@ def setUp(self):
             slug='file',
             path='file.html',
             md5='abcdef',
-            commit='1234567890abcdef')
+            commit='1234567890abcdef',
+        )
 
     def test_random_page_view_redirects(self):
         response = self.client.get('/random/')
@@ -188,7 +203,9 @@ def test_404_for_with_no_imported_files(self):
         response = self.client.get('/random/pip/')
         self.assertEqual(response.status_code, 404)
 
+
 class SubprojectViewTests(TestCase):
+
     def setUp(self):
         self.user = new(User, username='test')
         self.user.set_password('test')
@@ -201,10 +218,15 @@ def setUp(self):
         self.client.login(username='test', password='test')
 
     def test_deny_delete_for_non_project_admins(self):
-        response = self.client.get('/dashboard/my-mainproject/subprojects/delete/my-subproject/')
+        response = self.client.get(
+            '/dashboard/my-mainproject/subprojects/delete/my-subproject/'
+        )
         self.assertEqual(response.status_code, 404)
 
-        self.assertTrue(self.subproject in [r.child for r in self.project.subprojects.all()])
+        self.assertTrue(
+            self.subproject in
+            [r.child for r in self.project.subprojects.all()]
+        )
 
     def test_admins_can_delete_subprojects(self):
         self.project.users.add(self.user)
@@ -212,24 +234,56 @@ def test_admins_can_delete_subprojects(self):
 
         # URL doesn't exist anymore, 404
         response = self.client.get(
-            '/dashboard/my-mainproject/subprojects/delete/my-subproject/')
+            '/dashboard/my-mainproject/subprojects/delete/my-subproject/',
+        )
         self.assertEqual(response.status_code, 404)
         # This URL still doesn't accept GET, 405
         response = self.client.get(
-            '/dashboard/my-mainproject/subprojects/my-subproject/delete/')
+            '/dashboard/my-mainproject/subprojects/my-subproject/delete/',
+        )
         self.assertEqual(response.status_code, 405)
-        self.assertTrue(self.subproject in [r.child for r in self.project.subprojects.all()])
+        self.assertTrue(
+            self.subproject in
+            [r.child for r in self.project.subprojects.all()]
+        )
         # Test POST
         response = self.client.post(
-            '/dashboard/my-mainproject/subprojects/my-subproject/delete/')
+            '/dashboard/my-mainproject/subprojects/my-subproject/delete/',
+        )
         self.assertEqual(response.status_code, 302)
-        self.assertTrue(self.subproject not in [r.child for r in self.project.subprojects.all()])
-
-    def test_project_admins_can_delete_subprojects_that_they_are_not_admin_of(self):
+        self.assertTrue(
+            self.subproject not in
+            [r.child for r in self.project.subprojects.all()]
+        )
+
+    def test_project_admins_can_delete_subprojects_that_they_are_not_admin_of(
+            self
+    ):
         self.project.users.add(self.user)
         self.assertFalse(AdminPermission.is_admin(self.user, self.subproject))
 
         response = self.client.post(
-            '/dashboard/my-mainproject/subprojects/my-subproject/delete/')
+            '/dashboard/my-mainproject/subprojects/my-subproject/delete/',
+        )
         self.assertEqual(response.status_code, 302)
-        self.assertTrue(self.subproject not in [r.child for r in self.project.subprojects.all()])
+        self.assertTrue(
+            self.subproject not in
+            [r.child for r in self.project.subprojects.all()]
+        )
+
+
+class BuildViewTests(TestCase):
+    fixtures = ['eric', 'test_data']
+
+    def setUp(self):
+        self.client.login(username='eric', password='test')
+
+    @mock.patch('readthedocs.projects.tasks.update_docs_task')
+    def test_build_redirect(self, mock):
+        r = self.client.post('/projects/pip/builds/', {'version_slug': '0.8.1'})
+        build = Build.objects.filter(project__slug='pip').latest()
+        self.assertEqual(r.status_code, 302)
+        self.assertEqual(
+            r._headers['location'][1],
+            '/projects/pip/builds/%s/' % build.pk,
+        )