Skip to content

Commit 9716147

Browse files
authored
Allow users to change version slug (#11930)
Basically what I had in my old design doc #6273 - When changing the slug, we check that it's a valid one (lowercase, only number, etc), in case the slug is not valid, we suggest a valid slug based on the invalid one. Why not just transform the value to a valid slug? Changing the slug is a breaking operation, we want to avoid surprises for the user, if they set a slug to "foo/bar", and then the actual slug is "foo-bar", that's unexpected. - `project` is used as s hidden field, following the same pattern we already have for other forms (this way all normal validations like the unique constraint work instead of getting a 500). - Editing the slug is disabled for machine created versions (stable, latest). - I'm not restricting the usage of latest or stable, since those will be non-machine created, similar to what a user does already if they name a version stable/latest. - If a version was active and its slug was changed, the resources attached to the old slug are removed before triggering a new build. - Cache is always purged after deleting resources from the version. I was playing with using the "pattern" attribute for the input, so it validates the slug before submitting the form. ![vlcsnap-2025-01-28-12h30m33s792](https://github.com/user-attachments/assets/1b89a0fc-74e4-4f89-ab37-7498b55708f4) But I think showing the error with the suggested slug works better, maybe we can port that to JS instead as a future improvement. ![Screenshot 2025-01-28 at 12-30-10 rynoh-pdf - test-builds - Read the Docs](https://github.com/user-attachments/assets/a38bb6e8-295c-457c-9a77-702b574d082d) ### Decisions - Expose this as a feature flag first, or just document it right away? ### TODO - Allow changing the slug using the API. - Suggest a redirect from the old slug to the new one. The idea implemented here comes from this comment: readthedocs/meta#147 (reply in thread) ref readthedocs/meta#147
1 parent 2b59e1d commit 9716147

File tree

6 files changed

+325
-17
lines changed

6 files changed

+325
-17
lines changed

readthedocs/builds/forms.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,26 @@
2222
Version,
2323
VersionAutomationRule,
2424
)
25+
from readthedocs.builds.version_slug import generate_version_slug
26+
from readthedocs.projects.models import Feature
2527

2628

2729
class VersionForm(forms.ModelForm):
30+
project = forms.CharField(widget=forms.HiddenInput(), required=False)
31+
2832
class Meta:
2933
model = Version
3034
states_fields = ["active", "hidden"]
3135
privacy_fields = ["privacy_level"]
3236
fields = (
37+
"project",
38+
"slug",
3339
*states_fields,
3440
*privacy_fields,
3541
)
3642

3743
def __init__(self, *args, **kwargs):
44+
self.project = kwargs.pop("project")
3845
super().__init__(*args, **kwargs)
3946

4047
field_sets = [
@@ -66,11 +73,20 @@ def __init__(self, *args, **kwargs):
6673
)
6774
)
6875

76+
# Don't allow changing the slug of machine created versions
77+
# (stable/latest), as we rely on the slug to identify them.
78+
if self.instance and self.instance.machine:
79+
self.fields["slug"].disabled = True
80+
81+
if not self.project.has_feature(Feature.ALLOW_CHANGING_VERSION_SLUG):
82+
self.fields.pop("slug")
83+
6984
self.helper = FormHelper()
7085
self.helper.layout = Layout(*field_sets)
7186
# We need to know if the version was active before the update.
7287
# We use this value in the save method.
7388
self._was_active = self.instance.active if self.instance else False
89+
self._previous_slug = self.instance.slug if self.instance else None
7490

7591
def clean_active(self):
7692
active = self.cleaned_data["active"]
@@ -88,7 +104,41 @@ def _is_default_version(self):
88104
project = self.instance.project
89105
return project.default_version == self.instance.slug
90106

107+
def clean_slug(self):
108+
slug = self.cleaned_data["slug"]
109+
validated_slug = generate_version_slug(slug)
110+
if slug != validated_slug:
111+
msg = _(
112+
"The slug can contain lowercase letters, numbers, dots, dashes or underscores, "
113+
f"and it must start with a lowercase letter or a number. Consider using '{validated_slug}'."
114+
)
115+
raise forms.ValidationError(msg)
116+
117+
# NOTE: Django already checks for unique slugs and raises a ValidationError,
118+
# but that message is attached to the whole form instead of the the slug field.
119+
# So we do the check here to provide a better error message.
120+
if (
121+
self.project.versions.filter(slug=slug)
122+
.exclude(pk=self.instance.pk)
123+
.exists()
124+
):
125+
raise forms.ValidationError(_("A version with that slug already exists."))
126+
return slug
127+
128+
def clean_project(self):
129+
return self.project
130+
91131
def save(self, commit=True):
132+
# If the slug was changed, and the version was active,
133+
# we need to delete all the resources, since the old slug is used in several places.
134+
# NOTE: we call clean_resources with the previous slug,
135+
# as all resources are associated with that slug.
136+
if "slug" in self.changed_data and self._was_active:
137+
self.instance.clean_resources(version_slug=self._previous_slug)
138+
# We need to set the flag to False,
139+
# so the post_save method triggers a new build.
140+
self._was_active = False
141+
92142
obj = super().save(commit=commit)
93143
obj.post_save(was_active=self._was_active)
94144
return obj

readthedocs/builds/models.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ def delete(self, *args, **kwargs):
401401
clean_project_resources(self.project, self)
402402
super().delete(*args, **kwargs)
403403

404-
def clean_resources(self):
404+
def clean_resources(self, version_slug=None):
405405
"""
406406
Remove all resources from this version.
407407
@@ -410,6 +410,11 @@ def clean_resources(self):
410410
- Files from storage
411411
- Search index
412412
- Imported files
413+
414+
:param version_slug: The version slug to use.
415+
Version resources are stored using the version's slug,
416+
since slugs can change, we need to be able to provide a different slug
417+
sometimes to clean old resources.
413418
"""
414419
from readthedocs.projects.tasks.utils import clean_project_resources
415420

@@ -418,9 +423,14 @@ def clean_resources(self):
418423
project_slug=self.project.slug,
419424
version_slug=self.slug,
420425
)
421-
clean_project_resources(project=self.project, version=self)
426+
clean_project_resources(
427+
project=self.project,
428+
version=self,
429+
version_slug=version_slug,
430+
)
422431
self.built = False
423432
self.save()
433+
self.purge_cdn(version_slug=version_slug)
424434

425435
def save(self, *args, **kwargs):
426436
if not self.slug:
@@ -447,11 +457,25 @@ def post_save(self, was_active=False):
447457
# If the version is deactivated, we need to clean up the files.
448458
if was_active and not self.active:
449459
self.clean_resources()
460+
return
450461
# If the version is activated, we need to trigger a build.
451462
if not was_active and self.active:
452463
trigger_build(project=self.project, version=self)
453-
# Purge the cache from the CDN.
454-
version_changed.send(sender=self.__class__, version=self)
464+
# Purge the cache from the CDN for any other changes.
465+
self.purge_cdn()
466+
467+
def purge_cdn(self, version_slug=None):
468+
"""
469+
Purge the cache from the CDN.
470+
471+
:param version_slug: The version slug to use.
472+
Version resources are stored using the version's slug,
473+
since slugs can change, we need to be able to provide a different slug
474+
sometimes to clean old resources.
475+
"""
476+
version_changed.send(
477+
sender=self.__class__, version=self, version_slug=version_slug
478+
)
455479

456480
@property
457481
def identifier_friendly(self):
@@ -516,19 +540,24 @@ def get_conf_py_path(self):
516540
conf_py_path = os.path.relpath(conf_py_path, checkout_prefix)
517541
return conf_py_path
518542

519-
def get_storage_paths(self):
543+
def get_storage_paths(self, version_slug=None):
520544
"""
521545
Return a list of all build artifact storage paths for this version.
522546
547+
:param version_slug: The version slug to use.
548+
Version resources are stored using the version's slug,
549+
since slugs can change, we need to be able to provide a different slug
550+
sometimes to clean old resources.
523551
:rtype: list
524552
"""
525553
paths = []
526554

555+
slug = version_slug or self.slug
527556
for type_ in MEDIA_TYPES:
528557
paths.append(
529558
self.project.get_storage_path(
530559
type_=type_,
531-
version_slug=self.slug,
560+
version_slug=slug,
532561
include_file=False,
533562
version_type=self.type,
534563
)

readthedocs/projects/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1910,6 +1910,7 @@ def add_features(sender, **kwargs):
19101910
USE_PROXIED_APIS_WITH_PREFIX = "use_proxied_apis_with_prefix"
19111911
ALLOW_VERSION_WARNING_BANNER = "allow_version_warning_banner"
19121912
DONT_SYNC_WITH_REMOTE_REPO = "dont_sync_with_remote_repo"
1913+
ALLOW_CHANGING_VERSION_SLUG = "allow_changing_version_slug"
19131914

19141915
# Versions sync related features
19151916
SKIP_SYNC_TAGS = "skip_sync_tags"
@@ -1961,6 +1962,10 @@ def add_features(sender, **kwargs):
19611962
DONT_SYNC_WITH_REMOTE_REPO,
19621963
_("Remote repository: Don't keep project in sync with remote repository."),
19631964
),
1965+
(
1966+
ALLOW_CHANGING_VERSION_SLUG,
1967+
_("Dashboard: Allow changing the version slug."),
1968+
),
19641969
# Versions sync related features
19651970
(
19661971
SKIP_SYNC_BRANCHES,

readthedocs/projects/tasks/utils.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def remove_build_storage_paths(paths):
5050
build_media_storage.delete_directory(storage_path)
5151

5252

53-
def clean_project_resources(project, version=None):
53+
def clean_project_resources(project, version=None, version_slug=None):
5454
"""
5555
Delete all extra resources used by `version` of `project`.
5656
@@ -61,16 +61,22 @@ def clean_project_resources(project, version=None):
6161
- Imported files.
6262
6363
:param version: Version instance. If isn't given,
64-
all resources of `project` will be deleted.
64+
all resources of `project` will be deleted.
65+
:param version_slug: The version slug to use.
66+
Version resources are stored using the version's slug,
67+
since slugs can change, we need to be able to provide a different slug
68+
sometimes to clean old resources.
6569
6670
.. note::
6771
This function is usually called just before deleting project.
6872
Make sure to not depend on the project object inside the tasks.
6973
"""
74+
version_slug = version_slug or version.slug if version else None
75+
7076
# Remove storage paths
7177
storage_paths = []
7278
if version:
73-
storage_paths = version.get_storage_paths()
79+
storage_paths = version.get_storage_paths(version_slug=version_slug)
7480
else:
7581
storage_paths = project.get_storage_paths()
7682
remove_build_storage_paths.delay(storage_paths)
@@ -80,7 +86,7 @@ def clean_project_resources(project, version=None):
8086

8187
remove_search_indexes.delay(
8288
project_slug=project.slug,
83-
version_slug=version.slug if version else None,
89+
version_slug=version_slug,
8490
)
8591

8692
# Remove imported files

readthedocs/projects/views/private.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -275,11 +275,6 @@ def get_queryset(self):
275275
only_active=False,
276276
)
277277

278-
def get_form(self, data=None, files=None, **kwargs):
279-
# This overrides the method from `ProjectAdminMixin`,
280-
# since we don't have a project.
281-
return self.get_form_class()(data, files, **kwargs)
282-
283278
def form_valid(self, form):
284279
form.save()
285280
return HttpResponseRedirect(self.get_success_url())

0 commit comments

Comments
 (0)