diff --git a/readthedocs/api/v2/views/integrations.py b/readthedocs/api/v2/views/integrations.py index 065b3edfb41..4d240947919 100644 --- a/readthedocs/api/v2/views/integrations.py +++ b/readthedocs/api/v2/views/integrations.py @@ -6,6 +6,9 @@ import logging import re +from allauth.socialaccount.providers.github.provider import GitHubProvider +from allauth.socialaccount.models import SocialAccount + from django.shortcuts import get_object_or_404 from rest_framework import permissions, status from rest_framework.exceptions import NotFound, ParseError @@ -14,6 +17,7 @@ from rest_framework.status import HTTP_400_BAD_REQUEST from rest_framework.views import APIView +from readthedocs.core.permissions import AdminPermission from readthedocs.core.signals import ( webhook_bitbucket, webhook_github, @@ -27,12 +31,14 @@ trigger_sync_versions, ) from readthedocs.integrations.models import HttpExchange, Integration -from readthedocs.projects.models import Feature, Project +from readthedocs.oauth.tasks import sync_remote_repositories, sync_remote_repositories_organizations +from readthedocs.projects.models import Project, Feature log = logging.getLogger(__name__) GITHUB_EVENT_HEADER = 'HTTP_X_GITHUB_EVENT' GITHUB_SIGNATURE_HEADER = 'HTTP_X_HUB_SIGNATURE' +GITHUB_MEMBER = 'member' GITHUB_PUSH = 'push' GITHUB_PULL_REQUEST = 'pull_request' GITHUB_PULL_REQUEST_OPENED = 'opened' @@ -452,6 +458,27 @@ def handle_webhook(self): except KeyError: raise ParseError('Parameter "ref" is required') + # Re-sync repositories for the user if any permission has changed + if event == GITHUB_MEMBER: + uid = self.data.get('member').get('id') + socialaccount = SocialAccount.objects.get( + provider=GitHubProvider.id, + uid=uid, + ) + + # Retrieve all organization the user belongs to + organization_slugs = set( + AdminPermission.projects( + socialaccount.user, + admin=True, + member=True, + ).values_list('organizations__slug', flat=True) + ) + if organization_slugs: + sync_remote_repositories_organizations(organization_slugs=organization_slugs) + else: + sync_remote_repositories.delay(socialaccount.user.pk) + return None def _normalize_ref(self, ref): diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index 19b4be767b9..3296c56743c 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -908,6 +908,26 @@ def is_external(self): type = self.version.type return type == EXTERNAL + @property + def can_rebuild(self): + """ + Check if external build can be rebuilt. + + Rebuild can be done only if the build is external, + build version is active and + it's the latest build for the version. + see https://github.com/readthedocs/readthedocs.org/pull/6995#issuecomment-852918969 + """ + if self.is_external: + is_latest_build = ( + self == Build.objects.filter( + project=self.project, + version=self.version + ).only('id').first() + ) + return self.version and self.version.active and is_latest_build + return False + @property def external_version_name(self): if self.is_external: diff --git a/readthedocs/builds/views.py b/readthedocs/builds/views.py index 745c6d1973d..b8a1146b7d0 100644 --- a/readthedocs/builds/views.py +++ b/readthedocs/builds/views.py @@ -153,12 +153,6 @@ 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 diff --git a/readthedocs/oauth/services/github.py b/readthedocs/oauth/services/github.py index 366a3794092..0cafdf8393e 100644 --- a/readthedocs/oauth/services/github.py +++ b/readthedocs/oauth/services/github.py @@ -218,7 +218,7 @@ def get_webhook_data(self, project, integration): 'secret': integration.secret, 'content_type': 'json', }, - 'events': ['push', 'pull_request', 'create', 'delete'], + 'events': ['push', 'pull_request', 'create', 'delete', 'member'], }) def get_provider_data(self, project, integration): diff --git a/readthedocs/rtd_tests/tests/test_builds.py b/readthedocs/rtd_tests/tests/test_builds.py index 11e736f07fa..cf7fae3f7ec 100644 --- a/readthedocs/rtd_tests/tests/test_builds.py +++ b/readthedocs/rtd_tests/tests/test_builds.py @@ -779,6 +779,69 @@ def test_version_deleted(self): self.assertEqual(build.version_type, BRANCH) self.assertEqual(build.commit, 'a1b2c3') + def test_can_rebuild_with_regular_version(self): + build = get( + Build, + project=self.project, + version=self.version, + _config={'version': 1}, + ) + + self.assertFalse(build.can_rebuild) + + def test_can_rebuild_with_external_active_version(self): + # Turn the build version to EXTERNAL type. + self.version.type = EXTERNAL + self.version.active = True + self.version.save() + + external_build = get( + Build, + project=self.project, + version=self.version, + _config={'version': 1}, + ) + + self.assertTrue(external_build.can_rebuild) + + def test_can_rebuild_with_external_inactive_version(self): + # Turn the build version to EXTERNAL type. + self.version.type = EXTERNAL + self.version.active = False + self.version.save() + + external_build = get( + Build, + project=self.project, + version=self.version, + _config={'version': 1}, + ) + + self.assertFalse(external_build.can_rebuild) + + def test_can_rebuild_with_old_build(self): + # Turn the build version to EXTERNAL type. + self.version.type = EXTERNAL + self.version.active = True + self.version.save() + + old_external_build = get( + Build, + project=self.project, + version=self.version, + _config={'version': 1}, + ) + + latest_external_build = get( + Build, + project=self.project, + version=self.version, + _config={'version': 1}, + ) + + self.assertFalse(old_external_build.can_rebuild) + self.assertTrue(latest_external_build.can_rebuild) + @mock.patch('readthedocs.projects.tasks.update_docs_task') class DeDuplicateBuildTests(TestCase): diff --git a/readthedocs/sso/admin.py b/readthedocs/sso/admin.py index 88e915c854f..4ad48516d3a 100644 --- a/readthedocs/sso/admin.py +++ b/readthedocs/sso/admin.py @@ -1,8 +1,52 @@ """Admin interface for SSO models.""" +import logging -from django.contrib import admin +from django.contrib import admin, messages + +from readthedocs.core.permissions import AdminPermission +from readthedocs.oauth.tasks import sync_remote_repositories from .models import SSODomain, SSOIntegration -admin.site.register(SSOIntegration) + +log = logging.getLogger(__name__) + + +class SSOIntegrationAdmin(admin.ModelAdmin): + + """Admin configuration for SSOIntegration.""" + + list_display = ('organization', 'provider') + search_fields = ('organization__slug', 'organization__name', 'domains__domain') + list_filter = ('provider',) + + actions = [ + 'resync_sso_user_accounts', + ] + + def resync_sso_user_accounts(self, request, queryset): # pylint: disable=no-self-use + users_count = 0 + organizations_count = queryset.count() + + for ssointegration in queryset.select_related('organization'): + members = AdminPermission.members(ssointegration.organization) + log.info( + 'Triggering SSO re-sync for organization. organization=%s users=%s', + ssointegration.organization.slug, + members.count(), + ) + users_count += members.count() + for user in members: + sync_remote_repositories.delay(user.pk) + + messages.add_message( + request, + messages.INFO, + f'Triggered resync for {organizations_count} organizations and {users_count} users.' + ) + + resync_sso_user_accounts.short_description = 'Re-sync all SSO user accounts' + + +admin.site.register(SSOIntegration, SSOIntegrationAdmin) admin.site.register(SSODomain) diff --git a/readthedocs/templates/builds/build_detail.html b/readthedocs/templates/builds/build_detail.html index 793f52b4b46..a9e36ea81a7 100644 --- a/readthedocs/templates/builds/build_detail.html +++ b/readthedocs/templates/builds/build_detail.html @@ -70,10 +70,9 @@ - - {# Show rebuild button only if the version is external and it's the latest build for this version #} + {# Show rebuild button only if the version is external, active 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 %} + {% if request.user|is_admin:project and build.can_rebuild %}