Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow users to change version slug #6204

Closed
wants to merge 17 commits into from
13 changes: 8 additions & 5 deletions docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,17 @@ as it `breaks the internet <http://www.w3.org/Provider/Style/URI.html>`_.
If that isn't enough,
you can request the change sending an email to support@readthedocs.org.


How do I change the version slug of my project?
-----------------------------------------------

We don't support allowing folks to change the slug for their versions.
But you can rename the branch/tag to achieve this.
If that isn't enough,
you can request the change sending an email to support@readthedocs.org.
You can achieve this by renaming the branch/tag of your version.
But if that isn't possible,
you can rename the slug from the :guilabel:`Versions` tab.

.. warning::

Be careful, changing the slug will break existing URLs.
stsewd marked this conversation as resolved.
Show resolved Hide resolved


Help, my build passed but my documentation page is 404 Not Found!
-----------------------------------------------------------------
Expand Down
46 changes: 43 additions & 3 deletions readthedocs/builds/forms.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
# -*- coding: utf-8 -*-

"""Django forms for the builds app."""

from django import forms
from django.utils.translation import ugettext_lazy as _

from readthedocs.builds.models import Version
from readthedocs.builds.version_slug import (
VERSION_OK_CHARS,
VERSION_TEST_PATTERN,
VersionSlugify,
)
from readthedocs.core.mixins import HideProtectedLevelMixin
from readthedocs.core.utils import trigger_build

Expand All @@ -14,7 +17,44 @@ class VersionForm(HideProtectedLevelMixin, forms.ModelForm):

class Meta:
model = Version
fields = ['active', 'privacy_level']
fields = ['slug', 'active', 'privacy_level']

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['slug'].help_text = _('The name of the version that you can see on your project\'s URL.')

if self.instance.pk and self.instance.machine:
self.fields['slug'].disabled = True

def clean_slug(self):
slugifier = VersionSlugify(
ok_chars=VERSION_OK_CHARS,
test_pattern=VERSION_TEST_PATTERN,
)
original_slug = self.cleaned_data.get('slug')

slug = slugifier.slugify(original_slug)
if not slug:
ok_chars = ', '.join(VERSION_OK_CHARS)
msg = _(
'The slug "{slug}" is not valid. '
'It should only contain letters, numbers or {ok_chars}. '
stsewd marked this conversation as resolved.
Show resolved Hide resolved
'And can not start with {ok_chars}.'
stsewd marked this conversation as resolved.
Show resolved Hide resolved
)
raise forms.ValidationError(
msg.format(slug=original_slug, ok_chars=ok_chars)
)

duplicated = (
Version.objects
.filter(project=self.instance.project, slug=slug)
.exclude(pk=self.instance.pk)
.exists()
)
if duplicated:
msg = _('The slug "{slug}" is already in use.')
raise forms.ValidationError(msg.format(slug=slug))
return slug

def clean_active(self):
active = self.cleaned_data['active']
Expand Down
99 changes: 63 additions & 36 deletions readthedocs/builds/version_slug.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-

"""
Contains logic for handling version slugs.

Expand Down Expand Up @@ -50,36 +48,23 @@ def get_fields_with_model(cls):
# (?: ... ) -- wrap everything so that the pattern cannot escape when used in
# regexes.
VERSION_SLUG_REGEX = '(?:[a-z0-9A-Z][-._a-z0-9A-Z]*?)'
VERSION_OK_CHARS = '-._' # dash, dot, underscore
VERSION_TEST_PATTERN = re.compile('^{pattern}$'.format(pattern=VERSION_SLUG_REGEX))
VERSION_FALLBACK_SLUG = 'unknown'


class VersionSlugField(models.CharField):
class VersionSlugify:
stsewd marked this conversation as resolved.
Show resolved Hide resolved

"""
Inspired by ``django_extensions.db.fields.AutoSlugField``.
Generates a valid slug for a version.

Uses ``unicode-slugify`` to generate the slug.
"""

ok_chars = '-._' # dash, dot, underscore
test_pattern = re.compile('^{pattern}$'.format(pattern=VERSION_SLUG_REGEX))
fallback_slug = 'unknown'

def __init__(self, *args, **kwargs):
kwargs.setdefault('db_index', True)

populate_from = kwargs.pop('populate_from', None)
if populate_from is None:
raise ValueError("missing 'populate_from' argument")
else:
self._populate_from = populate_from
super().__init__(*args, **kwargs)

def get_queryset(self, model_cls, slug_field):
# pylint: disable=protected-access
for field, model in get_fields_with_model(model_cls):
if model and field == slug_field:
return model._default_manager.all()
return model_cls._default_manager.all()
def __init__(self, ok_chars, test_pattern, fallback_slug=''):
self.ok_chars = ok_chars
self.test_pattern = test_pattern
self.fallback_slug = fallback_slug

def _normalize(self, content):
"""
Expand All @@ -94,18 +79,23 @@ def _normalize(self, content):
"""
return re.sub('[/%!?]', '-', content)

def is_valid(self, content):
return self.test_pattern.match(content)

def slugify(self, content):
"""
Make ``content`` a valid slug.

It uses ``unicode-slugify`` behind the scenes which works properly with
Unicode characters.

:returns: `None` if isn't possible to generate a valid slug.
"""
if not content:
return ''
return None

normalized = self._normalize(content)
slugified = unicode_slugify(
slug = unicode_slugify(
normalized,
only_ascii=True,
spaces=False,
Expand All @@ -114,13 +104,40 @@ def slugify(self, content):
space_replacement='-',
)

# Remove first character wile it's an invalid character for the
# Remove first character while it's an invalid character for the
# beginning of the slug
slugified = slugified.lstrip(self.ok_chars)
slug = slug.lstrip(self.ok_chars)
slug = slug or self.fallback_slug

if not self.is_valid(slug):
return None
return slug


if not slugified:
return self.fallback_slug
return slugified
class VersionSlugField(models.CharField):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did these files change? There's nothing in the PR description about what logic changes are here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes reusable the code to create valid slugs, logic is the same


"""Inspired by ``django_extensions.db.fields.AutoSlugField``."""

ok_chars = VERSION_OK_CHARS
test_pattern = VERSION_TEST_PATTERN
fallback_slug = VERSION_FALLBACK_SLUG

def __init__(self, *args, **kwargs):
kwargs.setdefault('db_index', True)

populate_from = kwargs.pop('populate_from', None)
if populate_from is None:
raise ValueError("missing 'populate_from' argument")
else:
self._populate_from = populate_from
super().__init__(*args, **kwargs)

def get_queryset(self, model_cls, slug_field):
# pylint: disable=protected-access
for field, model in get_fields_with_model(model_cls):
if model and field == slug_field:
return model._default_manager.all()
return model_cls._default_manager.all()

def uniquifying_suffix(self, iteration):
"""
Expand Down Expand Up @@ -157,11 +174,21 @@ def create_slug(self, model_instance):
"""Generate a unique slug for a model instance."""
# pylint: disable=protected-access

slugifier = VersionSlugify(
ok_chars=self.ok_chars,
test_pattern=self.test_pattern,
fallback_slug=self.fallback_slug,
)

# get fields to populate from and slug field to set
slug_field = model_instance._meta.get_field(self.attname)

slug = self.slugify(getattr(model_instance, self._populate_from))
count = 0
content = getattr(model_instance, self._populate_from)
slug = slugifier.slugify(content=content)
if slug is None:
# If we weren't able to generate a valid slug based on the name
# we can still generate one with a suffix.
slug = ''

# strip slug depending on max_length attribute of the slug field
# and clean-up
Expand All @@ -186,6 +213,7 @@ def create_slug(self, model_instance):

# increases the number while searching for the next valid slug
# depending on the given slug, clean-up
count = 0
while not slug or queryset.filter(**kwargs).exists():
slug = original_slug
end = self.uniquifying_suffix(count)
Expand All @@ -196,9 +224,8 @@ def create_slug(self, model_instance):
kwargs[self.attname] = slug
count += 1

is_slug_valid = self.test_pattern.match(slug)
if not is_slug_valid:
raise Exception('Invalid generated slug: {slug}'.format(slug=slug))
if not slugifier.is_valid(slug):
raise Exception(f'Invalid generated slug: {slug}')
return slug

def pre_save(self, model_instance, add):
Expand Down
Loading