Skip to content

Commit c537b05

Browse files
committed
Merge pull request #1396 from rtfd/fix-slug-generation-for-uppercase
Fix slug generation for uppercase branch names
2 parents 5b59828 + db52f37 commit c537b05

File tree

3 files changed

+199
-10
lines changed

3 files changed

+199
-10
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# -*- coding: utf-8 -*-
2+
from south.utils import datetime_utils as datetime
3+
from south.db import db
4+
from south.v2 import DataMigration
5+
from django.db import models
6+
7+
class Migration(DataMigration):
8+
9+
def forwards(self, orm):
10+
"Write your forwards methods here."
11+
# Note: Don't use "from appname.models import ModelName".
12+
# Use orm.ModelName to refer to models in this application,
13+
# and orm['appname.ModelName'] for models in other applications.
14+
15+
Version = orm['builds.Version']
16+
slug_field = Version._meta.get_field_by_name('slug')[0]
17+
18+
bad_slug = Version.objects.exclude(
19+
slug__regex=r'^[a-z0-9][-._a-z0-9]+$')
20+
for version in bad_slug:
21+
version.slug = slug_field.create_slug(version)
22+
version.save()
23+
24+
def backwards(self, orm):
25+
"Write your backwards methods here."
26+
27+
# No backwards operation possible.
28+
29+
models = {
30+
u'auth.group': {
31+
'Meta': {'object_name': 'Group'},
32+
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
33+
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
34+
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
35+
},
36+
u'auth.permission': {
37+
'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'},
38+
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
39+
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
40+
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
41+
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
42+
},
43+
u'auth.user': {
44+
'Meta': {'object_name': 'User'},
45+
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
46+
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
47+
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
48+
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}),
49+
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
50+
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
51+
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
52+
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
53+
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
54+
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
55+
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
56+
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}),
57+
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
58+
},
59+
u'builds.build': {
60+
'Meta': {'ordering': "['-date']", 'object_name': 'Build'},
61+
'builder': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
62+
'commit': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
63+
'date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
64+
'error': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}),
65+
'exit_code': ('django.db.models.fields.IntegerField', [], {'max_length': '3', 'null': 'True', 'blank': 'True'}),
66+
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
67+
'length': ('django.db.models.fields.IntegerField', [], {'max_length': '10', 'null': 'True', 'blank': 'True'}),
68+
'output': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}),
69+
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'builds'", 'to': u"orm['projects.Project']"}),
70+
'setup': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
71+
'setup_error': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
72+
'state': ('django.db.models.fields.CharField', [], {'default': "'finished'", 'max_length': '55'}),
73+
'success': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
74+
'type': ('django.db.models.fields.CharField', [], {'default': "'html'", 'max_length': '55'}),
75+
'version': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'builds'", 'null': 'True', 'to': u"orm['builds.Version']"})
76+
},
77+
u'builds.version': {
78+
'Meta': {'ordering': "['-verbose_name']", 'unique_together': "[('project', 'slug')]", 'object_name': 'Version'},
79+
'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
80+
'built': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
81+
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
82+
'identifier': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
83+
'machine': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
84+
'privacy_level': ('django.db.models.fields.CharField', [], {'default': "'public'", 'max_length': '20'}),
85+
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'versions'", 'to': u"orm['projects.Project']"}),
86+
'slug': ('builds.version_slug.VersionSlugField', [], {'max_length': '255', 'populate_from': "'verbose_name'", 'db_index': 'True'}),
87+
'supported': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
88+
'type': ('django.db.models.fields.CharField', [], {'default': "'unknown'", 'max_length': '20'}),
89+
'uploaded': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
90+
'verbose_name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
91+
},
92+
u'builds.versionalias': {
93+
'Meta': {'object_name': 'VersionAlias'},
94+
'from_slug': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255'}),
95+
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
96+
'largest': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
97+
'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'aliases'", 'to': u"orm['projects.Project']"}),
98+
'to_slug': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'blank': 'True'})
99+
},
100+
u'contenttypes.contenttype': {
101+
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
102+
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
103+
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
104+
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
105+
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
106+
},
107+
u'projects.project': {
108+
'Meta': {'ordering': "('slug',)", 'object_name': 'Project'},
109+
'allow_comments': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
110+
'analytics_code': ('django.db.models.fields.CharField', [], {'max_length': '50', 'null': 'True', 'blank': 'True'}),
111+
'canonical_url': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}),
112+
'comment_moderation': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
113+
'conf_py_file': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'blank': 'True'}),
114+
'copyright': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
115+
'default_branch': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '255', 'null': 'True', 'blank': 'True'}),
116+
'default_version': ('django.db.models.fields.CharField', [], {'default': "'latest'", 'max_length': '255'}),
117+
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
118+
'django_packages_url': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
119+
'documentation_type': ('django.db.models.fields.CharField', [], {'default': "'auto'", 'max_length': '20'}),
120+
'enable_epub_build': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
121+
'enable_pdf_build': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
122+
'featured': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
123+
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
124+
'language': ('django.db.models.fields.CharField', [], {'default': "'en'", 'max_length': '20'}),
125+
'main_language_project': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'translations'", 'null': 'True', 'to': u"orm['projects.Project']"}),
126+
'mirror': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
127+
'modified_date': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
128+
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
129+
'num_major': ('django.db.models.fields.IntegerField', [], {'default': '2', 'max_length': '3', 'null': 'True', 'blank': 'True'}),
130+
'num_minor': ('django.db.models.fields.IntegerField', [], {'default': '2', 'max_length': '3', 'null': 'True', 'blank': 'True'}),
131+
'num_point': ('django.db.models.fields.IntegerField', [], {'default': '2', 'max_length': '3', 'null': 'True', 'blank': 'True'}),
132+
'path': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
133+
'privacy_level': ('django.db.models.fields.CharField', [], {'default': "'public'", 'max_length': '20'}),
134+
'programming_language': ('django.db.models.fields.CharField', [], {'default': "'words'", 'max_length': '20', 'blank': 'True'}),
135+
'project_url': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}),
136+
'pub_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
137+
'python_interpreter': ('django.db.models.fields.CharField', [], {'default': "'python'", 'max_length': '20'}),
138+
'related_projects': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'to': u"orm['projects.Project']", 'null': 'True', 'through': u"orm['projects.ProjectRelationship']", 'blank': 'True'}),
139+
'repo': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
140+
'repo_type': ('django.db.models.fields.CharField', [], {'default': "'git'", 'max_length': '10'}),
141+
'requirements_file': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '255', 'null': 'True', 'blank': 'True'}),
142+
'single_version': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
143+
'skip': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
144+
'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '255'}),
145+
'suffix': ('django.db.models.fields.CharField', [], {'default': "'.rst'", 'max_length': '10'}),
146+
'theme': ('django.db.models.fields.CharField', [], {'default': "'default'", 'max_length': '20'}),
147+
'use_system_packages': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
148+
'use_virtualenv': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
149+
'users': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'projects'", 'symmetrical': 'False', 'to': u"orm['auth.User']"}),
150+
'version': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
151+
'version_privacy_level': ('django.db.models.fields.CharField', [], {'default': "'public'", 'max_length': '20'})
152+
},
153+
u'projects.projectrelationship': {
154+
'Meta': {'object_name': 'ProjectRelationship'},
155+
'child': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'superprojects'", 'to': u"orm['projects.Project']"}),
156+
u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
157+
'parent': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'subprojects'", 'to': u"orm['projects.Project']"})
158+
}
159+
}
160+
161+
complete_apps = ['builds']
162+
symmetrical = True

readthedocs/builds/version_slug.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,16 @@
3333
VERSION_SLUG_REGEX = '(?:[a-z0-9][-._a-z0-9]+?)'
3434

3535

36-
version_slug_regex = re.compile(VERSION_SLUG_REGEX)
37-
38-
3936
class VersionSlugField(models.CharField):
4037
"""
4138
Implementation inspired by ``django_extensions.db.fields.AutoSlugField``.
4239
"""
4340

44-
allowed_chars = string.lowercase + string.digits + '-._'
41+
invalid_chars_re = re.compile('[^-._a-z0-9]')
42+
leading_punctuation_re = re.compile('^[-._]+')
4543
placeholder = '-'
44+
fallback_slug = 'unknown'
45+
test_pattern = re.compile('^{pattern}$'.format(pattern=VERSION_SLUG_REGEX))
4646

4747
def __init__(self, *args, **kwargs):
4848
kwargs.setdefault('db_index', True)
@@ -63,12 +63,13 @@ def get_queryset(self, model_cls, slug_field):
6363
def slugify(self, content):
6464
if not content:
6565
return ''
66-
slugified = ''
67-
for char in content:
68-
if char not in self.allowed_chars:
69-
slugified += self.placeholder
70-
else:
71-
slugified += char
66+
67+
slugified = content.lower()
68+
slugified = self.invalid_chars_re.sub(self.placeholder, slugified)
69+
slugified = self.leading_punctuation_re.sub('', slugified)
70+
71+
if not slugified:
72+
return self.fallback_slug
7273
return slugified
7374

7475
def uniquifying_suffix(self, iteration):
@@ -139,6 +140,9 @@ def create_slug(self, model_instance):
139140
slug = slug + end
140141
kwargs[self.attname] = slug
141142
next += 1
143+
144+
assert self.test_pattern.match(slug), (
145+
'Invalid generated slug: {slug}'.format(slug=slug))
142146
return slug
143147

144148
def pre_save(self, model_instance, add):

readthedocs/rtd_tests/tests/test_version_slug.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,29 @@ def test_normalizing_slashes(self):
2929
project=self.pip)
3030
self.assertEqual(version.slug, 'releases-1.0')
3131

32+
def test_uppercase(self):
33+
version = Version.objects.create(
34+
verbose_name='SomeString-charclass',
35+
project=self.pip)
36+
self.assertEqual(version.slug, 'somestring-charclass')
37+
38+
def test_placeholder_as_name(self):
39+
version = Version.objects.create(
40+
verbose_name='-',
41+
project=self.pip)
42+
self.assertEqual(version.slug, 'unknown')
43+
44+
def test_multiple_empty_names(self):
45+
version = Version.objects.create(
46+
verbose_name='-',
47+
project=self.pip)
48+
self.assertEqual(version.slug, 'unknown')
49+
50+
version = Version.objects.create(
51+
verbose_name='-./.-',
52+
project=self.pip)
53+
self.assertEqual(version.slug, 'unknown_a')
54+
3255
def test_uniqueness(self):
3356
version = Version.objects.create(
3457
verbose_name='1!0',

0 commit comments

Comments
 (0)