-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
/
version_slug.py
183 lines (147 loc) · 6.52 KB
/
version_slug.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# -*- coding: utf-8 -*-
"""
Contains logic for handling version slugs.
Handling slugs for versions is not too straightforward. We need to allow some
characters which are uncommon in usual slugs. They are dots and underscores.
Usually we want the slug to be the name of the tag or branch corresponding VCS
version. However we need to strip url-destroying characters like slashes.
So the syntax for version slugs should be:
* Start with a lowercase ascii char or a digit.
* All other characters must be lowercase ascii chars, digits or dots.
If uniqueness is not met for a slug in a project, we append a dash and a letter
starting with ``a``. We keep increasing that letter until we have a unique
slug. This is used since using numbers in tags is too common and appending
another number would be confusing.
"""
import math
import re
import string
from operator import truediv
from django.db import models
from django.utils.encoding import force_text
def get_fields_with_model(cls):
"""
Replace deprecated function of the same name in Model._meta.
This replaces deprecated function (as of Django 1.10) in Model._meta as
prescrived in the Django docs.
https://docs.djangoproject.com/en/1.11/ref/models/meta/#migrating-from-the-old-api
"""
return [(f, f.model if f.model != cls else None)
for f in cls._meta.get_fields()
if not f.is_relation or f.one_to_one or
(f.many_to_one and f.related_model)]
# Regex breakdown:
# [a-z0-9] -- start with alphanumeric value
# [-._a-z0-9] -- allow dash, dot, underscore, digit, lowercase ascii
# *? -- allow multiple of those, but be not greedy about the matching
# (?: ... ) -- wrap everything so that the pattern cannot escape when used in
# regexes.
VERSION_SLUG_REGEX = '(?:[a-z0-9A-Z][-._a-z0-9A-Z]*?)'
class VersionSlugField(models.CharField):
"""Inspired by ``django_extensions.db.fields.AutoSlugField``."""
invalid_chars_re = re.compile('[^-._a-z0-9]')
leading_punctuation_re = re.compile('^[-._]+')
placeholder = '-'
fallback_slug = 'unknown'
test_pattern = re.compile('^{pattern}$'.format(pattern=VERSION_SLUG_REGEX))
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 slugify(self, content):
if not content:
return ''
slugified = content.lower()
slugified = self.invalid_chars_re.sub(self.placeholder, slugified)
slugified = self.leading_punctuation_re.sub('', slugified)
if not slugified:
return self.fallback_slug
return slugified
def uniquifying_suffix(self, iteration):
"""
Create a unique suffix.
This creates a suffix based on the number given as ``iteration``. It
will return a value encoded as lowercase ascii letter. So we have an
alphabet of 26 letters. The returned suffix will be for example ``_yh``
where ``yh`` is the encoding of ``iteration``. The length of it will be
``math.log(iteration, 26)``.
Examples::
uniquifying_suffix(0) == '_a'
uniquifying_suffix(25) == '_z'
uniquifying_suffix(26) == '_ba'
uniquifying_suffix(52) == '_ca'
"""
alphabet = string.ascii_lowercase
length = len(alphabet)
if iteration == 0:
power = 0
else:
power = int(math.log(iteration, length))
current = iteration
suffix = ''
for exp in reversed(list(range(0, power + 1))):
digit = int(truediv(current, length ** exp))
suffix += alphabet[digit]
current = current % length ** exp
return '_{suffix}'.format(suffix=suffix)
def create_slug(self, model_instance):
"""Generate a unique slug for a model instance."""
# pylint: disable=protected-access
# 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
# strip slug depending on max_length attribute of the slug field
# and clean-up
slug_len = slug_field.max_length
if slug_len:
slug = slug[:slug_len]
original_slug = slug
# exclude the current model instance from the queryset used in finding
# the next valid slug
queryset = self.get_queryset(model_instance.__class__, slug_field)
if model_instance.pk:
queryset = queryset.exclude(pk=model_instance.pk)
# form a kwarg dict used to implement any unique_together constraints
kwargs = {}
for params in model_instance._meta.unique_together:
if self.attname in params:
for param in params:
kwargs[param] = getattr(model_instance, param, None)
kwargs[self.attname] = slug
# increases the number while searching for the next valid slug
# depending on the given slug, clean-up
while not slug or queryset.filter(**kwargs).exists():
slug = original_slug
end = self.uniquifying_suffix(count)
end_len = len(end)
if slug_len and len(slug) + end_len > slug_len:
slug = slug[:slug_len - end_len]
slug = slug + end
kwargs[self.attname] = slug
count += 1
assert self.test_pattern.match(slug), (
'Invalid generated slug: {slug}'.format(slug=slug)
)
return slug
def pre_save(self, model_instance, add):
value = getattr(model_instance, self.attname)
# We only create a new slug if none was set yet.
if not value and add:
value = force_text(self.create_slug(model_instance))
setattr(model_instance, self.attname, value)
return value
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
kwargs['populate_from'] = self._populate_from
return name, path, args, kwargs