diff --git a/readthedocs/audit/migrations/0009_reduce_slug_max_length.py b/readthedocs/audit/migrations/0009_reduce_slug_max_length.py new file mode 100644 index 00000000000..c81eb72d7f8 --- /dev/null +++ b/readthedocs/audit/migrations/0009_reduce_slug_max_length.py @@ -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", + ), + ), + ] diff --git a/readthedocs/audit/models.py b/readthedocs/audit/models.py index e800c4d16a4..26105d4f8a7 100644 --- a/readthedocs/audit/models.py +++ b/readthedocs/audit/models.py @@ -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, diff --git a/readthedocs/organizations/forms.py b/readthedocs/organizations/forms.py index 33eef6fd77f..b9d743245d5 100644 --- a/readthedocs/organizations/forms.py +++ b/readthedocs/organizations/forms.py @@ -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: diff --git a/readthedocs/projects/forms.py b/readthedocs/projects/forms.py index 9eb4e705d93..92aba19f1f9 100644 --- a/readthedocs/projects/forms.py +++ b/readthedocs/projects/forms.py @@ -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"), diff --git a/readthedocs/projects/migrations/0156_reduce_slug_max_length.py b/readthedocs/projects/migrations/0156_reduce_slug_max_length.py new file mode 100644 index 00000000000..34b57583165 --- /dev/null +++ b/readthedocs/projects/migrations/0156_reduce_slug_max_length.py @@ -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"), + ), + ] diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index 3cbba2315a4..395a23ad464 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -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, @@ -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") diff --git a/readthedocs/rtd_tests/tests/test_project_forms.py b/readthedocs/rtd_tests/tests/test_project_forms.py index 030bdbf3ec9..d753beaa633 100644 --- a/readthedocs/rtd_tests/tests/test_project_forms.py +++ b/readthedocs/rtd_tests/tests/test_project_forms.py @@ -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)