Skip to content

Commit 59cccbf

Browse files
committed
Add feature flipping models to Projects, plus example feature flip
Also, use a new pattern for API instance objects
1 parent fbe860f commit 59cccbf

File tree

7 files changed

+162
-10
lines changed

7 files changed

+162
-10
lines changed

readthedocs/doc_builder/python_environments.py

Lines changed: 9 additions & 2 deletions
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,14 @@ 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+
requirements.append('sphinx<1.7')
134+
else:
135+
requirements.append('sphinx==1.5.6')
136+
requirements.extend([
137+
'sphinx-rtd-theme<0.3',
138+
'readthedocs-sphinx-ext<0.6'
139+
])
133140

134141
cmd = [
135142
'python',

readthedocs/projects/admin.py

Lines changed: 9 additions & 2 deletions
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,14 @@ class DomainAdmin(admin.ModelAdmin):
180181
model = Domain
181182

182183

184+
class FeatureAdmin(admin.ModelAdmin):
185+
model = Feature
186+
form = FeatureForm
187+
188+
183189
admin.site.register(Project, ProjectAdmin)
184190
admin.site.register(ImportedFile, ImportedFileAdmin)
185191
admin.site.register(Domain, DomainAdmin)
192+
admin.site.register(Feature, FeatureAdmin)
186193
admin.site.register(EmailHook)
187194
admin.site.register(WebHook)

readthedocs/projects/forms.py

Lines changed: 21 additions & 1 deletion
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 = ['project', 'feature']
614+
615+
def __init__(self, *args, **kwargs):
616+
super(FeatureForm, self).__init__(*args, **kwargs)
617+
self.fields['feature'].choices = Feature.FEATURES
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.9.12 on 2017-10-26 14:40
3+
from __future__ import unicode_literals
4+
5+
from django.db import migrations, models
6+
import django.db.models.deletion
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('projects', '0018_fix-translation-model'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='Feature',
18+
fields=[
19+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('feature', models.CharField(max_length=32, verbose_name='Project feature')),
21+
],
22+
),
23+
migrations.AddField(
24+
model_name='feature',
25+
name='project',
26+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='projects.Project'),
27+
),
28+
]

readthedocs/projects/models.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,35 @@ def add_comment(self, version_slug, page, content_hash, commit, user, text):
821821
hash=content_hash, commit=commit)
822822
return node.comments.create(user=user, text=text)
823823

824+
def has_feature(self, feature):
825+
"""Does project have existing feature flag"""
826+
return self.features.filter(feature=feature).exists()
827+
828+
829+
class APIProject(Project):
830+
831+
"""Project object from API data deserialization
832+
833+
This is to preserve the Project model methods for use in builder instances.
834+
Properties can be read only here, as the builders don't need write access to
835+
a number of attributes.
836+
"""
837+
838+
features = []
839+
840+
class Meta:
841+
proxy = True
842+
843+
def __init__(self, *args, **kwargs):
844+
self.features = kwargs.pop('features', [])
845+
super(APIProject, self).__init__(*args, **kwargs)
846+
847+
def save(self, *args, **kwargs):
848+
return 0
849+
850+
def has_feature(self, feature):
851+
return feature in self.features
852+
824853

825854
@python_2_unicode_compatible
826855
class ImportedFile(models.Model):
@@ -921,3 +950,52 @@ def delete(self, *args, **kwargs): # pylint: disable=arguments-differ
921950
from readthedocs.projects import tasks
922951
broadcast(type='app', task=tasks.symlink_domain, args=[self.project.pk, self.pk, True])
923952
super(Domain, self).delete(*args, **kwargs)
953+
954+
955+
@python_2_unicode_compatible
956+
class Feature(models.Model):
957+
958+
"""Project feature flags
959+
960+
Features should generally be added here as choices, however features may
961+
also be added dynamically from a signal in other packages. See the
962+
FeatureForm implementation for this.
963+
964+
Features can be added by external packages with the use of signals::
965+
966+
@receiver(pre_init, sender=Feature)
967+
def add_features(sender, **kwargs):
968+
sender.FEATURES += (('blah', 'BLAH'),)
969+
970+
The FeatureForm will grab the updated list on instantiation.
971+
"""
972+
973+
# Feature constants - this is not a exhaustive list of features, features
974+
# may be added by other packages
975+
USE_SPHINX_LATEST = 'use_sphinx_latest'
976+
977+
FEATURES = (
978+
(USE_SPHINX_LATEST, _('Use latest version of Sphinx')),
979+
)
980+
981+
project = models.ForeignKey(
982+
Project,
983+
related_name='features',
984+
)
985+
# Feature is not implemented as a ChoiceField, as we don't want validation
986+
# at the database level on this field. Arbitrary values are allowed here.
987+
feature = models.CharField(
988+
_('Project feature'),
989+
max_length=32,
990+
)
991+
992+
def __str__(self):
993+
return "{0} feature for {1}".format(self.get_feature_display(), self.project)
994+
995+
def get_feature_display(self):
996+
"""Implement display name field for fake ChoiceField
997+
998+
Because choices are implemented in FeatureForm, we need to reimplement
999+
this here.
1000+
"""
1001+
return dict(self.FEATURES).get(self.feature, self.feature)

readthedocs/projects/utils.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,10 @@ def make_api_version(version_data):
192192

193193
def make_api_project(project_data):
194194
"""Make mock Project instance from API return"""
195-
from readthedocs.projects.models import Project
195+
from readthedocs.projects.models import APIProject
196196
for key in ['users', 'resource_uri', 'absolute_url', 'downloads',
197197
'main_language_project', 'related_projects']:
198198
if key in project_data:
199199
del project_data[key]
200-
project = Project(**project_data)
201-
project.save = _new_save
200+
project = APIProject(**project_data)
202201
return project

readthedocs/restapi/serializers.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
"""Defines serializers for each of our models."""
22

33
from __future__ import absolute_import
4-
from builtins import object
54

5+
from builtins import object
66
from rest_framework import serializers
77

88
from readthedocs.builds.models import Build, BuildCommandResult, Version
9-
from readthedocs.projects.models import Project, Domain
109
from readthedocs.oauth.models import RemoteOrganization, RemoteRepository
10+
from readthedocs.projects.models import Project, Domain
1111

1212

1313
class ProjectSerializer(serializers.ModelSerializer):
@@ -28,6 +28,18 @@ class Meta(object):
2828

2929
class ProjectAdminSerializer(ProjectSerializer):
3030

31+
"""Project serializer for admin only access
32+
33+
Includes special internal fields that don't need to be exposed through the
34+
general API, mostly for fields used in the build process
35+
"""
36+
37+
features = serializers.SlugRelatedField(
38+
many=True,
39+
read_only=True,
40+
slug_field='feature',
41+
)
42+
3143
class Meta(ProjectSerializer.Meta):
3244
fields = ProjectSerializer.Meta.fields + (
3345
'enable_epub_build',
@@ -44,6 +56,7 @@ class Meta(ProjectSerializer.Meta):
4456
'skip',
4557
'requirements_file',
4658
'python_interpreter',
59+
'features',
4760
)
4861

4962

0 commit comments

Comments
 (0)