diff --git a/readthedocs/builds/views.py b/readthedocs/builds/views.py index 3167570752f..83b3cdf57f9 100644 --- a/readthedocs/builds/views.py +++ b/readthedocs/builds/views.py @@ -48,20 +48,58 @@ class BuildTriggerMixin: @method_decorator(login_required) def post(self, request, project_slug): + commit_to_retrigger = None project = get_object_or_404(Project, slug=project_slug) if not AdminPermission.is_admin(request.user, project): return HttpResponseForbidden() version_slug = request.POST.get('version_slug') - version = get_object_or_404( - self._get_versions(project), - slug=version_slug, - ) + build_pk = request.POST.get('build_pk') + + if build_pk: + # Filter over external versions only when re-triggering a specific build + version = get_object_or_404( + Version.external.public(self.request.user), + slug=version_slug, + project=project, + ) + + build_to_retrigger = get_object_or_404( + Build.objects.all(), + pk=build_pk, + version=version, + ) + if build_to_retrigger != Build.objects.filter(version=version).first(): + messages.add_message( + request, + messages.ERROR, + "This build can't be re-triggered because it's " + "not the latest build for this version.", + ) + return HttpResponseRedirect(request.path) + + # Set either the build to re-trigger it or None + if build_to_retrigger: + commit_to_retrigger = build_to_retrigger.commit + log.info( + 'Re-triggering build. project=%s version=%s commit=%s build=%s', + project.slug, + version.slug, + build_to_retrigger.commit, + build_to_retrigger.pk + ) + else: + # Use generic query when triggering a normal build + version = get_object_or_404( + self._get_versions(project), + slug=version_slug, + ) update_docs_task, build = trigger_build( project=project, version=version, + commit=commit_to_retrigger, ) if (update_docs_task, build) == (None, None): # Build was skipped @@ -111,6 +149,13 @@ def get_context_data(self, **kwargs): context['project'] = self.project build = self.get_object() + context['is_latest_build'] = ( + build == Build.objects.filter( + project=build.project, + version=build.version, + ).first() + ) + if build.error != BuildEnvironmentError.GENERIC_WITH_BUILD_ID.format(build_id=build.pk): # Do not suggest to open an issue if the error is not generic return context diff --git a/readthedocs/core/utils/__init__.py b/readthedocs/core/utils/__init__.py index f0d39509db2..8ac316b4809 100644 --- a/readthedocs/core/utils/__init__.py +++ b/readthedocs/core/utils/__init__.py @@ -13,8 +13,8 @@ from django.utils.text import slugify as slugify_base from readthedocs.builds.constants import ( - BUILD_STATE_FINISHED, BUILD_STATE_TRIGGERED, + BUILD_STATE_FINISHED, BUILD_STATUS_PENDING, EXTERNAL, ) @@ -65,7 +65,6 @@ def prepare_build( ) build = None - if not Project.objects.is_active(project): log.warning( 'Build not triggered because Project is not active: project=%s', @@ -234,11 +233,11 @@ def trigger_build(project, version=None, commit=None, record=True, force=False): commit, ) update_docs_task, build = prepare_build( - project, - version, - commit, - record, - force, + project=project, + version=version, + commit=commit, + record=record, + force=force, immutable=True, ) diff --git a/readthedocs/rtd_tests/tests/test_views.py b/readthedocs/rtd_tests/tests/test_views.py index 6085c2a5b51..d0929c30ef8 100644 --- a/readthedocs/rtd_tests/tests/test_views.py +++ b/readthedocs/rtd_tests/tests/test_views.py @@ -265,6 +265,71 @@ def test_build_list_includes_external_versions(self): self.assertEqual(response.status_code, 200) self.assertIn(external_version_build, response.context['build_qs']) + @mock.patch('readthedocs.projects.tasks.update_docs_task') + def test_rebuild_specific_commit(self, mock): + builds_count = Build.objects.count() + + version = self.pip.versions.first() + version.type = 'external' + version.save() + + build = get( + Build, + version=version, + project=self.pip, + commit='a1b2c3', + ) + + r = self.client.post( + '/projects/pip/builds/', + { + 'version_slug': version.slug, + 'build_pk': build.pk, + } + ) + + self.assertEqual(r.status_code, 302) + self.assertEqual(Build.objects.count(), builds_count + 2) + + newbuild = Build.objects.first() + self.assertEqual( + r._headers['location'][1], + f'/projects/pip/builds/{newbuild.pk}/', + ) + self.assertEqual(newbuild.commit, 'a1b2c3') + + + @mock.patch('readthedocs.projects.tasks.update_docs_task') + def test_rebuild_invalid_specific_commit(self, mock): + version = self.pip.versions.first() + version.type = 'external' + version.save() + + for i in range(3): + get( + Build, + version=version, + project=self.pip, + commit=f'a1b2c3-{i}', + ) + + build = Build.objects.filter( + version=version, + project=self.pip, + ).last() + + r = self.client.post( + '/projects/pip/builds/', + { + 'version_slug': version.slug, + 'build_pk': build.pk, + } + ) + + # It should return 302 and show a message to the user because we are + # re-triggering a build of a non-lastest build for that version + self.assertEqual(r.status_code, 302) + class TestSearchAnalyticsView(TestCase): diff --git a/readthedocs/templates/builds/build_detail.html b/readthedocs/templates/builds/build_detail.html index fd92dfeaf9c..6ce1c0dfe7a 100644 --- a/readthedocs/templates/builds/build_detail.html +++ b/readthedocs/templates/builds/build_detail.html @@ -69,6 +69,23 @@ + + + {# Show rebuild button only if the version is external and it's the latest build for this version #} + {# see https://github.com/readthedocs/readthedocs.org/pull/6995#issuecomment-852918969 #} + {% if request.user|is_admin:project and build.version.type == "external" and is_latest_build %} +
+
+ {% csrf_token %} + + Rebuild this build + + + +
+
+ {% endif %} +