Skip to content

Commit 040c787

Browse files
stsewdbenjaoming
andauthored
API V3: clean version when deactivated and build version when activated (#10308)
Closes #10221. --------- Co-authored-by: Benjamin Balder Bach <[email protected]>
1 parent 605bfa2 commit 040c787

File tree

7 files changed

+126
-24
lines changed

7 files changed

+126
-24
lines changed

docs/user/api/v3.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,11 @@ Version update
580580
581581
Update a version.
582582

583+
When a version is deactivated, its documentation is removed,
584+
and when it's activated, a new build is triggered.
585+
586+
Updates to a version also invalidates its CDN cache.
587+
583588
**Example request**:
584589

585590
.. tabs::

readthedocs/api/v3/tests/test_versions.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from unittest import mock
2+
13
import django_dynamic_fixture as fixture
24
from django.test import override_settings
35
from django.urls import reverse
@@ -139,6 +141,9 @@ def test_projects_versions_detail_unique(self):
139141
)
140142
self.assertEqual(response.status_code, 200)
141143

144+
@mock.patch(
145+
"readthedocs.projects.tasks.utils.clean_project_resources", new=mock.MagicMock
146+
)
142147
def test_projects_versions_partial_update(self):
143148
self.assertTrue(self.version.active)
144149
self.assertFalse(self.version.hidden)
@@ -166,7 +171,7 @@ def test_projects_versions_partial_update(self):
166171
self.assertEqual(self.version.identifier, 'a1b2c3')
167172
self.assertFalse(self.version.active)
168173
self.assertTrue(self.version.hidden)
169-
self.assertTrue(self.version.built)
174+
self.assertFalse(self.version.built)
170175
self.assertEqual(self.version.type, TAG)
171176

172177
def test_projects_versions_partial_update_privacy_levels_disabled(self):
@@ -227,6 +232,54 @@ def test_projects_versions_partial_update_invalid_privacy_levels(self):
227232
self.assertEqual(response.status_code, 400)
228233
self.assertEqual(self.version.privacy_level, "public")
229234

235+
@mock.patch("readthedocs.builds.models.trigger_build")
236+
@mock.patch("readthedocs.projects.tasks.utils.clean_project_resources")
237+
def test_activate_version(self, clean_project_resources, trigger_build):
238+
self.version.active = False
239+
self.version.save()
240+
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
241+
self.assertFalse(self.version.active)
242+
data = {"active": True}
243+
response = self.client.patch(
244+
reverse(
245+
"projects-versions-detail",
246+
kwargs={
247+
"parent_lookup_project__slug": self.project.slug,
248+
"version_slug": self.version.slug,
249+
},
250+
),
251+
data,
252+
)
253+
self.assertEqual(response.status_code, 204)
254+
self.version.refresh_from_db()
255+
self.assertTrue(self.version.active)
256+
clean_project_resources.assert_not_called()
257+
trigger_build.assert_called_once()
258+
259+
@mock.patch("readthedocs.builds.models.trigger_build")
260+
@mock.patch("readthedocs.projects.tasks.utils.clean_project_resources")
261+
def test_deactivate_version(self, clean_project_resources, trigger_build):
262+
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
263+
data = {"active": False}
264+
self.assertTrue(self.version.active)
265+
self.assertTrue(self.version.built)
266+
response = self.client.patch(
267+
reverse(
268+
"projects-versions-detail",
269+
kwargs={
270+
"parent_lookup_project__slug": self.project.slug,
271+
"version_slug": self.version.slug,
272+
},
273+
),
274+
data,
275+
)
276+
self.assertEqual(response.status_code, 204)
277+
self.version.refresh_from_db()
278+
self.assertFalse(self.version.active)
279+
self.assertFalse(self.version.built)
280+
clean_project_resources.assert_called_once()
281+
trigger_build.assert_not_called()
282+
230283
def test_projects_version_external(self):
231284
self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}")
232285
self.version.type = EXTERNAL

readthedocs/api/v3/views.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,17 @@ def get_serializer_class(self):
297297
return VersionSerializer
298298
return VersionUpdateSerializer
299299

300+
def update(self, request, *args, **kwargs):
301+
"""Overridden to call ``post_save`` method on the updated version."""
302+
# Get the current value before updating.
303+
version = self.get_object()
304+
was_active = version.active
305+
result = super().update(request, *args, **kwargs)
306+
# Get the updated version.
307+
version = self.get_object()
308+
version.post_save(was_active=was_active)
309+
return result
310+
300311

301312
class BuildsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin,
302313
FlexFieldsMixin, ReadOnlyModelViewSet):

readthedocs/builds/forms.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@
2222
Version,
2323
VersionAutomationRule,
2424
)
25-
from readthedocs.builds.signals import version_changed
26-
from readthedocs.core.utils import trigger_build
2725

2826

2927
class VersionForm(forms.ModelForm):
@@ -67,6 +65,9 @@ def __init__(self, *args, **kwargs):
6765

6866
self.helper = FormHelper()
6967
self.helper.layout = Layout(*field_sets)
68+
# We need to know if the version was active before the update.
69+
# We use this value in the save method.
70+
self._was_active = self.instance.active if self.instance else False
7071

7172
def clean_active(self):
7273
active = self.cleaned_data['active']
@@ -86,10 +87,7 @@ def _is_default_version(self):
8687

8788
def save(self, commit=True):
8889
obj = super().save(commit=commit)
89-
if obj.active and not obj.built and not obj.uploaded:
90-
trigger_build(project=obj.project, version=obj)
91-
if self.has_changed():
92-
version_changed.send(sender=self.__class__, version=obj)
90+
obj.post_save(was_active=self._was_active)
9391
return obj
9492

9593

readthedocs/builds/models.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
"""Models for the builds app."""
2-
32
import datetime
43
import os.path
54
import re
@@ -52,6 +51,7 @@
5251
RelatedBuildQuerySet,
5352
VersionQuerySet,
5453
)
54+
from readthedocs.builds.signals import version_changed
5555
from readthedocs.builds.utils import (
5656
external_version_name,
5757
get_bitbucket_username_repo,
@@ -61,6 +61,7 @@
6161
)
6262
from readthedocs.builds.version_slug import VersionSlugField
6363
from readthedocs.config import LATEST_CONFIGURATION_VERSION
64+
from readthedocs.core.utils import trigger_build
6465
from readthedocs.projects.constants import (
6566
BITBUCKET_COMMIT_URL,
6667
BITBUCKET_URL,
@@ -381,6 +382,50 @@ def delete(self, *args, **kwargs): # pylint: disable=arguments-differ
381382
clean_project_resources(self.project, self)
382383
super().delete(*args, **kwargs)
383384

385+
def clean_resources(self):
386+
"""
387+
Remove all resources from this version.
388+
389+
This includes removing files from storage,
390+
and removing its search index.
391+
"""
392+
from readthedocs.projects.tasks.utils import clean_project_resources
393+
394+
log.info(
395+
"Removing files for version.",
396+
project_slug=self.project.slug,
397+
version_slug=self.slug,
398+
)
399+
clean_project_resources(project=self.project, version=self)
400+
self.built = False
401+
self.save()
402+
403+
def post_save(self, was_active=False):
404+
"""
405+
Run extra steps after updating a version.
406+
407+
This method isn't called automatically by a signal but is called explicitly
408+
from other processes.
409+
410+
Useful to run after the version has been saved/updated
411+
by the user, like from a form or API.
412+
413+
- When a version is deactivated, we need to clean up its
414+
files from storage, and search index.
415+
- When a version is activated, we need to trigger a build.
416+
- We also need to purge the cache from the CDN,
417+
since the version could have been activated/deactivated,
418+
or its privacy level could have changed.
419+
"""
420+
# If the version is deactivated, we need to clean up the files.
421+
if was_active and not self.active:
422+
self.clean_resources()
423+
# If the version is activated, we need to trigger a build.
424+
if not was_active and self.active:
425+
trigger_build(project=self.project, version=self)
426+
# Purge the cache from the CDN.
427+
version_changed.send(sender=self.__class__, version=self)
428+
384429
@property
385430
def identifier_friendly(self):
386431
"""Return display friendly identifier."""

readthedocs/projects/views/private.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -215,19 +215,7 @@ def get_form(self, data=None, files=None, **kwargs):
215215
return self.get_form_class()(data, files, **kwargs)
216216

217217
def form_valid(self, form):
218-
version = form.save()
219-
if form.has_changed():
220-
if 'active' in form.changed_data and version.active is False:
221-
log.info(
222-
'Removing files for version.',
223-
version_slug=version.slug,
224-
)
225-
clean_project_resources(
226-
version.project,
227-
version,
228-
)
229-
version.built = False
230-
version.save()
218+
form.save()
231219
return HttpResponseRedirect(self.get_success_url())
232220

233221

readthedocs/rtd_tests/tests/test_build_forms.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,11 @@ def test_can_update_privacy_level(self):
9090
self.assertTrue(form.is_valid())
9191
self.assertEqual(version.privacy_level, PRIVATE)
9292

93-
@mock.patch('readthedocs.builds.forms.trigger_build', mock.MagicMock())
94-
@mock.patch('readthedocs.projects.views.private.clean_project_resources')
95-
def test_resources_are_deleted_when_version_is_inactive(self, clean_project_resources):
93+
@mock.patch("readthedocs.builds.models.trigger_build", mock.MagicMock())
94+
@mock.patch("readthedocs.projects.tasks.utils.clean_project_resources")
95+
def test_resources_are_deleted_when_version_is_inactive(
96+
self, clean_project_resources
97+
):
9698
version = get(
9799
Version,
98100
project=self.project,

0 commit comments

Comments
 (0)