Skip to content

Commit b65e2f3

Browse files
authored
Add feature flipping models to Projects, plus example feature flip (readthedocs#3194)
* Add feature flipping models to Projects, plus example feature flip Also, use a new pattern for API instance objects * Refactoring the make_api_* calls into the API models * Add migrations for proxy models * Add some admin features * Rework feature relationship, add default true value based on date This allows for features on deprecated code, where we can say the feature is true historically. * Fix some issues, more tests * Rename Feature.feature -> Feature.feature_id, other cleanup * Use semver instead
1 parent f482965 commit b65e2f3

File tree

15 files changed

+404
-59
lines changed

15 files changed

+404
-59
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.9.12 on 2017-10-27 00:17
3+
from __future__ import unicode_literals
4+
5+
from django.db import migrations
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('builds', '0003_add-cold-storage'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='APIVersion',
17+
fields=[
18+
],
19+
options={
20+
'proxy': True,
21+
},
22+
bases=('builds.version',),
23+
),
24+
]

readthedocs/builds/models.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from readthedocs.projects.constants import (PRIVACY_CHOICES, GITHUB_URL,
2727
GITHUB_REGEXS, BITBUCKET_URL,
2828
BITBUCKET_REGEXS, PRIVATE)
29-
from readthedocs.projects.models import Project
29+
from readthedocs.projects.models import Project, APIProject
3030

3131

3232
DEFAULT_VERSION_PRIVACY_LEVEL = getattr(settings, 'DEFAULT_VERSION_PRIVACY_LEVEL', 'public')
@@ -301,6 +301,40 @@ def get_bitbucket_url(self, docroot, filename, source_suffix='.rst'):
301301
)
302302

303303

304+
class APIVersion(Version):
305+
306+
"""Version proxy model for API data deserialization
307+
308+
This replaces the pattern where API data was deserialized into a mocked
309+
:py:cls:`Version` object. This pattern was confusing, as it was not explicit
310+
as to what form of object you were working with -- API backed or database
311+
backed.
312+
313+
This model preserves the Version model methods, allowing for overrides on
314+
model field differences. This model pattern will generally only be used on
315+
builder instances, where we are interacting solely with API data.
316+
"""
317+
318+
project = None
319+
320+
class Meta:
321+
proxy = True
322+
323+
def __init__(self, *args, **kwargs):
324+
self.project = APIProject(**kwargs.pop('project', {}))
325+
# These fields only exist on the API return, not on the model, so we'll
326+
# remove them to avoid throwing exceptions due to unexpected fields
327+
for key in ['resource_uri', 'absolute_url', 'downloads']:
328+
try:
329+
del kwargs[key]
330+
except KeyError:
331+
pass
332+
super(APIVersion, self).__init__(*args, **kwargs)
333+
334+
def save(self, *args, **kwargs):
335+
return 0
336+
337+
304338
@python_2_unicode_compatible
305339
class VersionAlias(models.Model):
306340

readthedocs/core/management/commands/update_api.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
import logging
1111

1212
from django.core.management.base import BaseCommand
13-
from readthedocs.projects import tasks
13+
1414
from readthedocs.api.client import api
15+
from readthedocs.projects import tasks
16+
from readthedocs.projects.models import APIProject
1517

1618

1719
log = logging.getLogger(__name__)
@@ -29,6 +31,6 @@ def handle(self, *args, **options):
2931
docker = options.get('docker', False)
3032
for slug in options['projects']:
3133
project_data = api.project(slug).get()
32-
p = tasks.make_api_project(project_data)
34+
p = APIProject(**project_data)
3335
log.info("Building %s", p)
3436
tasks.update_docs.run(pk=p.pk, docker=docker)

readthedocs/doc_builder/python_environments.py

+11-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from readthedocs.doc_builder.config import ConfigWrapper
1212
from readthedocs.doc_builder.loader import get_builder_class
1313
from readthedocs.projects.constants import LOG_TEMPLATE
14+
from readthedocs.projects.models import Feature
1415

1516
log = logging.getLogger(__name__)
1617

@@ -128,8 +129,16 @@ def install_core_requirements(self):
128129
if self.project.documentation_type == 'mkdocs':
129130
requirements.append('mkdocs==0.15.0')
130131
else:
131-
requirements.extend(['sphinx==1.5.3', 'sphinx-rtd-theme<0.3',
132-
'readthedocs-sphinx-ext<0.6'])
132+
if self.project.has_feature(Feature.USE_SPHINX_LATEST):
133+
# We will assume semver here and only automate up to the next
134+
# backward incompatible release: 2.x
135+
requirements.append('sphinx<2')
136+
else:
137+
requirements.append('sphinx==1.5.6')
138+
requirements.extend([
139+
'sphinx-rtd-theme<0.3',
140+
'readthedocs-sphinx-ext<0.6'
141+
])
133142

134143
cmd = [
135144
'python',

readthedocs/projects/admin.py

+16-2
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
from readthedocs.redirects.models import Redirect
1414
from readthedocs.notifications.views import SendNotificationView
1515

16-
from .notifications import ResourceUsageNotification
17-
from .models import (Project, ImportedFile,
16+
from .forms import FeatureForm
17+
from .models import (Project, ImportedFile, Feature,
1818
ProjectRelationship, EmailHook, WebHook, Domain)
19+
from .notifications import ResourceUsageNotification
1920
from .tasks import remove_dir
2021

2122

@@ -180,8 +181,21 @@ class DomainAdmin(admin.ModelAdmin):
180181
model = Domain
181182

182183

184+
class FeatureAdmin(admin.ModelAdmin):
185+
model = Feature
186+
form = FeatureForm
187+
list_display = ('feature_id', 'project_count', 'default_true')
188+
search_fields = ('feature_id',)
189+
filter_horizontal = ('projects',)
190+
readonly_fields = ('add_date',)
191+
192+
def project_count(self, feature):
193+
return feature.projects.count()
194+
195+
183196
admin.site.register(Project, ProjectAdmin)
184197
admin.site.register(ImportedFile, ImportedFileAdmin)
185198
admin.site.register(Domain, DomainAdmin)
199+
admin.site.register(Feature, FeatureAdmin)
186200
admin.site.register(EmailHook)
187201
admin.site.register(WebHook)

readthedocs/projects/forms.py

+21-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from readthedocs.projects import constants
2323
from readthedocs.projects.exceptions import ProjectSpamError
2424
from readthedocs.projects.models import (
25-
Project, ProjectRelationship, EmailHook, WebHook, Domain)
25+
Project, ProjectRelationship, EmailHook, WebHook, Domain, Feature)
2626
from readthedocs.redirects.models import Redirect
2727

2828

@@ -595,3 +595,23 @@ class Meta(object):
595595
def __init__(self, *args, **kwargs):
596596
self.project = kwargs.pop('project', None)
597597
super(ProjectAdvertisingForm, self).__init__(*args, **kwargs)
598+
599+
600+
class FeatureForm(forms.ModelForm):
601+
602+
"""Project feature form for dynamic admin choices
603+
604+
This form converts the CharField into a ChoiceField on display. The
605+
underlying driver won't attempt to do validation on the choices, and so we
606+
can dynamically populate this list.
607+
"""
608+
609+
feature = forms.ChoiceField()
610+
611+
class Meta(object):
612+
model = Feature
613+
fields = ['projects', 'feature', 'default_true']
614+
615+
def __init__(self, *args, **kwargs):
616+
super(FeatureForm, self).__init__(*args, **kwargs)
617+
self.fields['feature'].choices = Feature.FEATURES
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.9.12 on 2017-10-27 12:55
3+
from __future__ import unicode_literals
4+
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('projects', '0018_fix-translation-model'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='Feature',
17+
fields=[
18+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('feature_id', models.CharField(max_length=32, unique=True, verbose_name='Feature identifier')),
20+
('add_date', models.DateTimeField(auto_now_add=True, verbose_name='Date feature was added')),
21+
('default_true', models.BooleanField(default=False, verbose_name='Historical default is True')),
22+
],
23+
),
24+
migrations.AddField(
25+
model_name='feature',
26+
name='projects',
27+
field=models.ManyToManyField(blank=True, to='projects.Project'),
28+
),
29+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.9.12 on 2017-10-27 12:56
3+
from __future__ import unicode_literals
4+
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('projects', '0019_add-features'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='APIProject',
17+
fields=[
18+
],
19+
options={
20+
'proxy': True,
21+
},
22+
bases=('projects.project',),
23+
),
24+
]

readthedocs/projects/models.py

+115-3
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
from readthedocs.projects.querysets import (
2727
ProjectQuerySet,
2828
RelatedProjectQuerySet,
29-
ChildRelatedProjectQuerySet
29+
ChildRelatedProjectQuerySet,
30+
FeatureQuerySet,
3031
)
3132
from readthedocs.projects.templatetags.projects_tags import sort_version_aware
32-
from readthedocs.projects.utils import make_api_version
3333
from readthedocs.projects.version_handling import determine_stable_version, version_windows
3434
from readthedocs.restapi.client import api
3535
from readthedocs.vcs_support.backends import backend_cls
@@ -639,9 +639,10 @@ def get_latest_build(self, finished=True):
639639
return self.builds.filter(**kwargs).first()
640640

641641
def api_versions(self):
642+
from readthedocs.builds.models import APIVersion
642643
ret = []
643644
for version_data in api.project(self.pk).active_versions.get()['versions']:
644-
version = make_api_version(version_data)
645+
version = APIVersion(**version_data)
645646
ret.append(version)
646647
return sort_version_aware(ret)
647648

@@ -821,6 +822,57 @@ def add_comment(self, version_slug, page, content_hash, commit, user, text):
821822
hash=content_hash, commit=commit)
822823
return node.comments.create(user=user, text=text)
823824

825+
@property
826+
def features(self):
827+
return Feature.objects.for_project(self)
828+
829+
def has_feature(self, feature_id):
830+
"""Does project have existing feature flag
831+
832+
If the feature has a historical True value before the feature was added,
833+
we consider the project to have the flag. This is used for deprecating a
834+
feature or changing behavior for new projects
835+
"""
836+
return self.features.filter(feature_id=feature_id).exists()
837+
838+
839+
class APIProject(Project):
840+
841+
"""Project proxy model for API data deserialization
842+
843+
This replaces the pattern where API data was deserialized into a mocked
844+
:py:cls:`Project` object. This pattern was confusing, as it was not explicit
845+
as to what form of object you were working with -- API backed or database
846+
backed.
847+
848+
This model preserves the Project model methods, allowing for overrides on
849+
model field differences. This model pattern will generally only be used on
850+
builder instances, where we are interacting solely with API data.
851+
"""
852+
853+
features = []
854+
855+
class Meta:
856+
proxy = True
857+
858+
def __init__(self, *args, **kwargs):
859+
self.features = kwargs.pop('features', [])
860+
# These fields only exist on the API return, not on the model, so we'll
861+
# remove them to avoid throwing exceptions due to unexpected fields
862+
for key in ['users', 'resource_uri', 'absolute_url', 'downloads',
863+
'main_language_project', 'related_projects']:
864+
try:
865+
del kwargs[key]
866+
except KeyError:
867+
pass
868+
super(APIProject, self).__init__(*args, **kwargs)
869+
870+
def save(self, *args, **kwargs):
871+
return 0
872+
873+
def has_feature(self, feature_id):
874+
return feature_id in self.features
875+
824876

825877
@python_2_unicode_compatible
826878
class ImportedFile(models.Model):
@@ -921,3 +973,63 @@ def delete(self, *args, **kwargs): # pylint: disable=arguments-differ
921973
from readthedocs.projects import tasks
922974
broadcast(type='app', task=tasks.symlink_domain, args=[self.project.pk, self.pk, True])
923975
super(Domain, self).delete(*args, **kwargs)
976+
977+
978+
@python_2_unicode_compatible
979+
class Feature(models.Model):
980+
981+
"""Project feature flags
982+
983+
Features should generally be added here as choices, however features may
984+
also be added dynamically from a signal in other packages. Features can be
985+
added by external packages with the use of signals::
986+
987+
@receiver(pre_init, sender=Feature)
988+
def add_features(sender, **kwargs):
989+
sender.FEATURES += (('blah', 'BLAH'),)
990+
991+
The FeatureForm will grab the updated list on instantiation.
992+
"""
993+
994+
# Feature constants - this is not a exhaustive list of features, features
995+
# may be added by other packages
996+
USE_SPHINX_LATEST = 'use_sphinx_latest'
997+
998+
FEATURES = (
999+
(USE_SPHINX_LATEST, _('Use latest version of Sphinx')),
1000+
)
1001+
1002+
projects = models.ManyToManyField(
1003+
Project,
1004+
blank=True,
1005+
)
1006+
# Feature is not implemented as a ChoiceField, as we don't want validation
1007+
# at the database level on this field. Arbitrary values are allowed here.
1008+
feature_id = models.CharField(
1009+
_('Feature identifier'),
1010+
max_length=32,
1011+
unique=True,
1012+
)
1013+
add_date = models.DateTimeField(
1014+
_('Date feature was added'),
1015+
auto_now_add=True,
1016+
)
1017+
default_true = models.BooleanField(
1018+
_('Historical default is True'),
1019+
default=False,
1020+
)
1021+
1022+
objects = FeatureQuerySet.as_manager()
1023+
1024+
def __str__(self):
1025+
return "{0} feature".format(
1026+
self.get_feature_display(),
1027+
)
1028+
1029+
def get_feature_display(self):
1030+
"""Implement display name field for fake ChoiceField
1031+
1032+
Because the field is not a ChoiceField here, we need to manually
1033+
implement this behavior.
1034+
"""
1035+
return dict(self.FEATURES).get(self.feature_id, self.feature_id)

0 commit comments

Comments
 (0)