Skip to content

Commit 8aa3b58

Browse files
committed
Automation rules: add delete version action
Currently we automatically delete inactive versions when they are deleted from the repository. If the version is active we keep it, and users must deactivate it manually. With this rule users can automate that. Default versions can't deactivate it, users must change the default version first. This is the only rule that is run when a version is deleted. Closes #7637
1 parent f5b76e8 commit 8aa3b58

File tree

7 files changed

+184
-22
lines changed

7 files changed

+184
-22
lines changed

docs/automation-rules.rst

+16
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ Currently, the following actions are available:
6161
i.e. the version of your project that `/` redirects to.
6262
See more in :ref:`automatic-redirects:Root URL`.
6363
It also activates and builds the version.
64+
- **Delete version**: When a branch or tag is deleted from your repository,
65+
Read the Docs will delete it *only if isn't active*.
66+
This action allows you to delete *active* versions when a branch or tag is deleted from your repository.
6467

6568
.. note::
6669

@@ -69,6 +72,12 @@ Currently, the following actions are available:
6972
The stable version is also automatically updated at the same time.
7073
See more in :doc:`versions`.
7174

75+
.. note::
76+
77+
Default versions aren't deleted even if they match a rule.
78+
You can use the ``Set version as default`` action to change the default version
79+
before deleting the current one.
80+
7281
Order
7382
-----
7483

@@ -99,6 +108,13 @@ Activate only new branches that belong to the ``1.x`` release
99108
- Version type: ``Branch``
100109
- Action: ``Activate version``
101110

111+
Automatically delete an active version of a deleted branch
112+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
113+
114+
- Match: ``Any version``
115+
- Version type: ``Branch``
116+
- Action: ``Delete version``
117+
102118
Set as default new tags that have the ``-stable`` or ``-release`` suffix
103119
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
104120

readthedocs/api/v2/utils.py

+27-12
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
STABLE_VERBOSE_NAME,
1515
TAG,
1616
)
17-
from readthedocs.builds.models import Version
17+
from readthedocs.builds.models import RegexAutomationRule, Version
1818

1919
log = logging.getLogger(__name__)
2020

@@ -140,7 +140,12 @@ def _set_or_create_version(project, slug, version_id, verbose_name, type_):
140140

141141

142142
def delete_versions_from_db(project, version_data):
143-
"""Delete all versions not in the current repo."""
143+
"""
144+
Delete all versions not in the current repo.
145+
146+
:returns: The slug of the deleted versions from the database,
147+
and the slug of active versions that where deleted from the repository.
148+
"""
144149
# We use verbose_name for tags
145150
# because several tags can point to the same identifier.
146151
versions_tags = [
@@ -153,7 +158,6 @@ def delete_versions_from_db(project, version_data):
153158
to_delete_qs = (
154159
project.versions
155160
.exclude(uploaded=True)
156-
.exclude(active=True)
157161
.exclude(slug__in=NON_REPOSITORY_VERSIONS)
158162
)
159163

@@ -166,18 +170,23 @@ def delete_versions_from_db(project, version_data):
166170
identifier__in=versions_branches,
167171
)
168172

169-
ret_val = set(to_delete_qs.values_list('slug', flat=True))
170-
if ret_val:
173+
deleted_active_versions = set(
174+
to_delete_qs.filter(active=True).values_list('slug', flat=True)
175+
)
176+
177+
to_delete_qs = to_delete_qs.exclude(active=True)
178+
deleted_versions = set(to_delete_qs.values_list('slug', flat=True))
179+
if deleted_versions:
171180
log.info(
172181
'(Sync Versions) Deleted Versions: project=%s, versions=[%s]',
173-
project.slug, ' '.join(ret_val),
182+
project.slug, ' '.join(deleted_versions),
174183
)
175184
to_delete_qs.delete()
176185

177-
return ret_val
186+
return deleted_versions, deleted_active_versions
178187

179188

180-
def run_automation_rules(project, versions_slug):
189+
def run_automation_rules(project, added_versions, deleted_active_versions):
181190
"""
182191
Runs the automation rules on each version.
183192
@@ -188,10 +197,16 @@ def run_automation_rules(project, versions_slug):
188197
Currently the versions aren't sorted in any way,
189198
the same order is keeped.
190199
"""
191-
versions = project.versions.filter(slug__in=versions_slug)
192-
rules = project.automation_rules.all()
193-
for version, rule in itertools.product(versions, rules):
194-
rule.run(version)
200+
class_ = RegexAutomationRule
201+
actions = [
202+
(added_versions, class_.allowed_actions_on_create),
203+
(deleted_active_versions, class_.allowed_actions_on_delete),
204+
]
205+
for versions_slug, allowed_actions in actions:
206+
versions = project.versions.filter(slug__in=versions_slug)
207+
rules = project.automation_rules.filter(action__in=allowed_actions)
208+
for version, rule in itertools.product(versions, rules):
209+
rule.run(version)
195210

196211

197212
class RemoteOrganizationPagination(PageNumberPagination):

readthedocs/api/v2/views/model_views.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ def sync_versions(self, request, **kwargs): # noqa: D205
219219
type=BRANCH,
220220
)
221221
added_versions.update(ret_set)
222-
deleted_versions = delete_versions_from_db(project, data)
222+
deleted_versions, deleted_active_versions = delete_versions_from_db(project, data)
223223
except Exception as e:
224224
log.exception('Sync Versions Error')
225225
return Response(
@@ -233,7 +233,7 @@ def sync_versions(self, request, **kwargs): # noqa: D205
233233
# The order of added_versions isn't deterministic.
234234
# We don't track the commit time or any other metadata.
235235
# We usually have one version added per webhook.
236-
run_automation_rules(project, added_versions)
236+
run_automation_rules(project, added_versions, deleted_active_versions)
237237
except Exception:
238238
# Don't interrupt the request if something goes wrong
239239
# in the automation rules.

readthedocs/builds/automation_actions.py

+7
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,10 @@ def set_private_privacy_level(version, match_result, action_arg, *args, **kwargs
6262
"""Sets the privacy_level of the version to private."""
6363
version.privacy_level = PRIVATE
6464
version.save()
65+
66+
67+
def delete_version(version, match_result, action_arg, *args, **kwargs):
68+
"""Delete a version if isn't marked as the default version."""
69+
if version.project.default_version == version.slug:
70+
return
71+
version.delete()

readthedocs/builds/models.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,7 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):
947947
"""Versions automation rules for projects."""
948948

949949
ACTIVATE_VERSION_ACTION = 'activate-version'
950+
DELETE_VERSION_ACTION = 'delete-version'
950951
HIDE_VERSION_ACTION = 'hide-version'
951952
MAKE_VERSION_PUBLIC_ACTION = 'make-version-public'
952953
MAKE_VERSION_PRIVATE_ACTION = 'make-version-private'
@@ -958,8 +959,12 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):
958959
(MAKE_VERSION_PUBLIC_ACTION, _('Make version public')),
959960
(MAKE_VERSION_PRIVATE_ACTION, _('Make version private')),
960961
(SET_DEFAULT_VERSION_ACTION, _('Set version as default')),
962+
(DELETE_VERSION_ACTION, _('Delete version (on deletion)')),
961963
)
962964

965+
allowed_actions_on_create = {}
966+
allowed_actions_on_delete = {}
967+
963968
project = models.ForeignKey(
964969
Project,
965970
related_name='automation_rules',
@@ -1052,14 +1057,17 @@ def match(self, version, match_arg):
10521057

10531058
def apply_action(self, version, match_result):
10541059
"""
1055-
Apply the action from allowed_actions.
1060+
Apply the action from allowed_actions_on_*.
10561061
10571062
:type version: readthedocs.builds.models.Version
10581063
:param any match_result: Additional context from the match operation
10591064
:raises: NotImplementedError if the action
10601065
isn't implemented or supported for this rule.
10611066
"""
1062-
action = self.allowed_actions.get(self.action)
1067+
action = (
1068+
self.allowed_actions_on_create.get(self.action)
1069+
or self.allowed_actions_on_delete.get(self.action)
1070+
)
10631071
if action is None:
10641072
raise NotImplementedError
10651073
action(version, match_result, self.action_arg)
@@ -1166,14 +1174,18 @@ class RegexAutomationRule(VersionAutomationRule):
11661174

11671175
TIMEOUT = 1 # timeout in seconds
11681176

1169-
allowed_actions = {
1177+
allowed_actions_on_create = {
11701178
VersionAutomationRule.ACTIVATE_VERSION_ACTION: actions.activate_version,
11711179
VersionAutomationRule.HIDE_VERSION_ACTION: actions.hide_version,
11721180
VersionAutomationRule.MAKE_VERSION_PUBLIC_ACTION: actions.set_public_privacy_level,
11731181
VersionAutomationRule.MAKE_VERSION_PRIVATE_ACTION: actions.set_private_privacy_level,
11741182
VersionAutomationRule.SET_DEFAULT_VERSION_ACTION: actions.set_default_version,
11751183
}
11761184

1185+
allowed_actions_on_delete = {
1186+
VersionAutomationRule.DELETE_VERSION_ACTION: actions.delete_version,
1187+
}
1188+
11771189
class Meta:
11781190
proxy = True
11791191

readthedocs/rtd_tests/tests/test_automation_rules.py

+49-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from unittest import mock
2+
23
import pytest
34
from django_dynamic_fixture import get
45

@@ -9,12 +10,12 @@
910
SEMVER_VERSIONS,
1011
TAG,
1112
)
12-
from readthedocs.projects.constants import PUBLIC, PRIVATE
1313
from readthedocs.builds.models import (
1414
RegexAutomationRule,
1515
Version,
1616
VersionAutomationRule,
1717
)
18+
from readthedocs.projects.constants import PRIVATE, PUBLIC
1819
from readthedocs.projects.models import Project
1920

2021

@@ -183,6 +184,53 @@ def test_action_activation(self, trigger_build):
183184
assert version.active is True
184185
trigger_build.assert_called_once()
185186

187+
@pytest.mark.parametrize('version_type', [BRANCH, TAG])
188+
def test_action_delete_version(self, version_type):
189+
slug = 'delete-me'
190+
version = get(
191+
Version,
192+
slug=slug,
193+
verbose_name=slug,
194+
project=self.project,
195+
active=True,
196+
type=version_type,
197+
)
198+
rule = get(
199+
RegexAutomationRule,
200+
project=self.project,
201+
priority=0,
202+
match_arg='.*',
203+
action=VersionAutomationRule.DELETE_VERSION_ACTION,
204+
version_type=version_type,
205+
)
206+
assert rule.run(version) is True
207+
assert not self.project.versions.filter(slug=slug).exists()
208+
209+
@pytest.mark.parametrize('version_type', [BRANCH, TAG])
210+
def test_action_delete_version_on_default_version(self, version_type):
211+
slug = 'delete-me'
212+
version = get(
213+
Version,
214+
slug=slug,
215+
verbose_name=slug,
216+
project=self.project,
217+
active=True,
218+
type=version_type,
219+
)
220+
self.project.default_version = slug
221+
self.project.save()
222+
223+
rule = get(
224+
RegexAutomationRule,
225+
project=self.project,
226+
priority=0,
227+
match_arg='.*',
228+
action=VersionAutomationRule.DELETE_VERSION_ACTION,
229+
version_type=version_type,
230+
)
231+
assert rule.run(version) is True
232+
assert self.project.versions.filter(slug=slug).exists()
233+
186234
def test_action_set_default_version(self):
187235
version = get(
188236
Version,

readthedocs/rtd_tests/tests/test_sync_versions.py

+68-4
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
# -*- coding: utf-8 -*-
2-
31
import json
42
from unittest import mock
53

64
from django.test import TestCase
75
from django.urls import reverse
86

9-
from readthedocs.builds.constants import BRANCH, STABLE, TAG, LATEST
7+
from readthedocs.builds.constants import BRANCH, LATEST, STABLE, TAG
108
from readthedocs.builds.models import (
119
RegexAutomationRule,
1210
Version,
@@ -774,7 +772,9 @@ def test_automation_rules_are_triggered_for_new_versions(self, run_automation_ru
774772
content_type='application/json',
775773
)
776774
run_automation_rules.assert_called_with(
777-
self.pip, {'new_branch', 'new_tag'}
775+
self.pip,
776+
{'new_branch', 'new_tag'},
777+
{'0.8', '0.8.1'},
778778
)
779779

780780
def test_automation_rule_activate_version(self):
@@ -837,6 +837,70 @@ def test_automation_rule_set_default_version(self):
837837
self.pip.refresh_from_db()
838838
self.assertEqual(self.pip.get_default_version(), 'new_tag')
839839

840+
def test_automation_rule_delete_version(self):
841+
version_post_data = {
842+
'tags': [
843+
{
844+
'identifier': 'new_tag',
845+
'verbose_name': 'new_tag',
846+
},
847+
{
848+
'identifier': '0.8.3',
849+
'verbose_name': '0.8.3',
850+
},
851+
],
852+
}
853+
version_slug = '0.8'
854+
RegexAutomationRule.objects.create(
855+
project=self.pip,
856+
priority=0,
857+
match_arg=r'^0\.8$',
858+
action=VersionAutomationRule.DELETE_VERSION_ACTION,
859+
version_type=TAG,
860+
)
861+
version = self.pip.versions.get(slug=version_slug)
862+
self.assertTrue(version.active)
863+
864+
self.client.post(
865+
reverse('project-sync-versions', args=[self.pip.pk]),
866+
data=json.dumps(version_post_data),
867+
content_type='application/json',
868+
)
869+
self.assertFalse(self.pip.versions.filter(slug=version_slug).exists())
870+
871+
def test_automation_rule_dont_delete_default_version(self):
872+
version_post_data = {
873+
'tags': [
874+
{
875+
'identifier': 'new_tag',
876+
'verbose_name': 'new_tag',
877+
},
878+
{
879+
'identifier': '0.8.3',
880+
'verbose_name': '0.8.3',
881+
},
882+
],
883+
}
884+
version_slug = '0.8'
885+
RegexAutomationRule.objects.create(
886+
project=self.pip,
887+
priority=0,
888+
match_arg=r'^0\.8$',
889+
action=VersionAutomationRule.DELETE_VERSION_ACTION,
890+
version_type=TAG,
891+
)
892+
version = self.pip.versions.get(slug=version_slug)
893+
self.assertTrue(version.active)
894+
895+
self.pip.default_version = version_slug
896+
self.pip.save()
897+
898+
self.client.post(
899+
reverse('project-sync-versions', args=[self.pip.pk]),
900+
data=json.dumps(version_post_data),
901+
content_type='application/json',
902+
)
903+
self.assertTrue(self.pip.versions.filter(slug=version_slug).exists())
840904

841905
class TestStableVersion(TestCase):
842906
fixtures = ['eric', 'test_data']

0 commit comments

Comments
 (0)