Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions readthedocs/audit/migrations/0009_reduce_slug_max_length.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 5.2.4 on 2025-01-XX XX:XX
from django.db import migrations
from django.db import models
from django_safemigrate import Safe


class Migration(migrations.Migration):
safe = Safe.after_deploy()

dependencies = [
("audit", "0008_alter_auditlog_action"),
]

operations = [
migrations.AlterField(
model_name="auditlog",
name="log_project_slug",
field=models.CharField(
max_length=55,
blank=True,
null=True,
db_index=True,
verbose_name="Project slug",
),
),
]
2 changes: 1 addition & 1 deletion readthedocs/audit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ class AuditLog(TimeStampedModel):
)
log_project_slug = models.CharField(
_("Project slug"),
max_length=63,
max_length=55,
blank=True,
null=True,
db_index=True,
Expand Down
2 changes: 1 addition & 1 deletion readthedocs/organizations/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class OrganizationForm(SimpleHistoryModelForm):

# We use the organization slug + project name
# to form the final project slug.
# A valid project slug is 63 chars long.
# A valid project slug is 55 chars long.
name = forms.CharField(max_length=32)

class Meta:
Expand Down
3 changes: 3 additions & 0 deletions readthedocs/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ def clean_name(self):
name = self.cleaned_data.get("name", "")
if not self.instance.pk:
potential_slug = slugify(name)
# Truncate slug to 55 characters to accommodate PR build suffixes
if len(potential_slug) > 55:
potential_slug = potential_slug[:55]
if Project.objects.filter(slug=potential_slug).exists():
raise forms.ValidationError(
_("Invalid project name, a project already exists with that name"),
Expand Down
53 changes: 53 additions & 0 deletions readthedocs/projects/migrations/0156_reduce_slug_max_length.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Generated by Django 5.2.4 on 2025-01-XX XX:XX
from django.db import migrations
from django.db import models
from django_safemigrate import Safe


def forwards_func(apps, schema_editor):
"""
Update max_length for Project slug and name fields to 55 characters.

PR builds use the format {project.slug}--{pr.number}, so we need to leave
room for the suffix. With max 6 digits for PR numbers + 2 for '--', we need
to reduce the max from 63 to 55 characters.

Note: We do NOT truncate existing project slugs as that would break their
documentation URLs. The max_length change only applies to new projects.
Existing projects with slugs longer than 55 characters will continue to work.
"""
# No data migration needed - we only change the field max_length
# which doesn't affect existing data
pass


class Migration(migrations.Migration):
safe = Safe.after_deploy()

dependencies = [
("projects", "0155_custom_git_checkout_step"),
]

operations = [
migrations.RunPython(forwards_func),
migrations.AlterField(
model_name="project",
name="slug",
field=models.SlugField(max_length=55, unique=True, verbose_name="Slug"),
),
migrations.AlterField(
model_name="project",
name="name",
field=models.CharField(max_length=55, verbose_name="Name"),
),
migrations.AlterField(
model_name="historicalproject",
name="slug",
field=models.SlugField(max_length=55, verbose_name="Slug", db_index=True),
),
migrations.AlterField(
model_name="historicalproject",
name="name",
field=models.CharField(max_length=55, verbose_name="Name"),
),
]
8 changes: 6 additions & 2 deletions readthedocs/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,9 @@ class Project(models.Model):
related_name="projects",
)
# A DNS label can contain up to 63 characters.
name = models.CharField(_("Name"), max_length=63)
slug = models.SlugField(_("Slug"), max_length=63, unique=True)
# We limit to 55 to account for PR build suffixes (--{pr_number}).
name = models.CharField(_("Name"), max_length=55)
slug = models.SlugField(_("Slug"), max_length=55, unique=True)
description = models.TextField(
_("Description"),
blank=True,
Expand Down Expand Up @@ -668,6 +669,9 @@ def save(self, *args, **kwargs):
if not self.slug:
# Subdomains can't have underscores in them.
self.slug = slugify(self.name)
# Truncate slug to 55 characters to accommodate PR build suffixes
if len(self.slug) > 55:
self.slug = self.slug[:55]
if not self.slug:
raise Exception( # pylint: disable=broad-exception-raised
_("Model must have slug")
Expand Down
26 changes: 26 additions & 0 deletions readthedocs/rtd_tests/tests/test_project_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,32 @@ def test_empty_slug(self):
self.assertFalse(form.is_valid())
self.assertIn("name", form.errors)

def test_slug_too_long(self):
"""Test that project names that generate slugs longer than 55 chars are truncated."""
# Test with a name that generates a 56-character slug
long_name = "a" * 56
initial = {
"name": long_name,
"repo_type": "git",
"repo": "https://github.com/user/repository",
"language": "en",
}
form = ProjectBasicsForm(initial)
self.assertTrue(form.is_valid())

def test_slug_max_length(self):
"""Test that project names that generate exactly 55-character slugs are accepted."""
# Test with a name that generates exactly 55-character slug
max_name = "a" * 55
initial = {
"name": max_name,
"repo_type": "git",
"repo": "https://github.com/user/repository",
"language": "en",
}
form = ProjectBasicsForm(initial)
self.assertTrue(form.is_valid())

@override_settings(ALLOW_PRIVATE_REPOS=False)
def test_length_of_tags(self):
project = get(Project)
Expand Down