Skip to content

Commit 634d879

Browse files
authored
Addons: allow users to opt-in into the beta addons (#10733)
* Addons: allow users to opt-in into the beta addons I created the URL, models, view and form to prepare ourselves for the beta addons. Currently, if the `Project.addons` fields is not null we add a HTTP header to tell Cloudflare to enforce the new beta addons on this project. In the future, we can use each of the `ProjectAddonsConfig` field to let the user decide which of the addons they want to enable/disable and change their configurations. * Related: readthedocs/ext-theme#212 * Changes from feedback Use a `AddonsConfig.enabled` field to decide whether or not all the addons are enabled. * Docstring to document the HTTP headers to communicate with CF * Addons: update modelling and queryset * NGINX: pass the `X-RTD-Force-Addons` HTTP header * Declare the URL only when ext-theme is enabled
1 parent 66e8a51 commit 634d879

File tree

8 files changed

+270
-5
lines changed

8 files changed

+270
-5
lines changed

dockerfiles/nginx/proxito.conf.template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ server {
9393
# Now, I'm injecting it in all the NGINX responses because `sub_filter` is not allowed inside an `if` statement.
9494
set $rtd_hosting_integrations $upstream_http_x_rtd_hosting_integrations;
9595
add_header X-RTD-Hosting-Integrations $rtd_hosting_integrations always;
96+
set $rtd_force_addons $upstream_http_x_rtd_force_addons;
97+
add_header X-RTD-Force-Addons $rtd_force_addons always;
9698

9799
# Inject our own script dynamically
98100
# TODO: find a way to make this work _without_ running `npm run dev` from the `addons` repository

readthedocs/projects/forms.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from readthedocs.invitations.models import Invitation
2323
from readthedocs.oauth.models import RemoteRepository
2424
from readthedocs.projects.models import (
25+
AddonsConfig,
2526
Domain,
2627
EmailHook,
2728
EnvironmentVariable,
@@ -493,6 +494,30 @@ def clean_alias(self):
493494
return alias
494495

495496

497+
class AddonsConfigForm(forms.ModelForm):
498+
499+
"""Form to opt-in into new beta addons."""
500+
501+
project = forms.CharField(widget=forms.HiddenInput(), required=False)
502+
503+
class Meta:
504+
model = AddonsConfig
505+
fields = ("enabled", "project")
506+
507+
def __init__(self, *args, **kwargs):
508+
self.project = kwargs.pop("project", None)
509+
kwargs["instance"] = getattr(self.project, "addons", None)
510+
super().__init__(*args, **kwargs)
511+
512+
try:
513+
self.fields["enabled"].initial = self.project.addons.enabled
514+
except AddonsConfig.DoesNotExist:
515+
self.fields["enabled"].initial = False
516+
517+
def clean_project(self):
518+
return self.project
519+
520+
496521
class UserForm(forms.Form):
497522

498523
"""Project owners form."""
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Generated by Django 4.2.5 on 2023-09-18 11:57
2+
3+
import django.db.models.deletion
4+
import django_extensions.db.fields
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("projects", "0105_remove_project_urlconf"),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="AddonsConfig",
16+
fields=[
17+
(
18+
"id",
19+
models.AutoField(
20+
auto_created=True,
21+
primary_key=True,
22+
serialize=False,
23+
verbose_name="ID",
24+
),
25+
),
26+
(
27+
"created",
28+
django_extensions.db.fields.CreationDateTimeField(
29+
auto_now_add=True, verbose_name="created"
30+
),
31+
),
32+
(
33+
"modified",
34+
django_extensions.db.fields.ModificationDateTimeField(
35+
auto_now=True, verbose_name="modified"
36+
),
37+
),
38+
(
39+
"enabled",
40+
models.BooleanField(
41+
default=True,
42+
help_text="Enable/Disable all the addons on this project",
43+
),
44+
),
45+
("analytics_enabled", models.BooleanField(default=True)),
46+
("doc_diff_enabled", models.BooleanField(default=True)),
47+
("doc_diff_show_additions", models.BooleanField(default=True)),
48+
("doc_diff_show_deletions", models.BooleanField(default=True)),
49+
(
50+
"doc_diff_root_selector",
51+
models.CharField(blank=True, max_length=128, null=True),
52+
),
53+
("external_version_warning_enabled", models.BooleanField(default=True)),
54+
("ethicalads_enabled", models.BooleanField(default=True)),
55+
("flyout_enabled", models.BooleanField(default=True)),
56+
("hotkeys_enabled", models.BooleanField(default=True)),
57+
("search_enabled", models.BooleanField(default=True)),
58+
(
59+
"search_default_filter",
60+
models.CharField(blank=True, max_length=128, null=True),
61+
),
62+
(
63+
"stable_latest_version_warning_enabled",
64+
models.BooleanField(default=True),
65+
),
66+
(
67+
"project",
68+
models.OneToOneField(
69+
blank=True,
70+
null=True,
71+
on_delete=django.db.models.deletion.CASCADE,
72+
related_name="addons",
73+
to="projects.project",
74+
),
75+
),
76+
],
77+
options={
78+
"get_latest_by": "modified",
79+
"abstract": False,
80+
},
81+
),
82+
migrations.CreateModel(
83+
name="AddonSearchFilter",
84+
fields=[
85+
(
86+
"id",
87+
models.AutoField(
88+
auto_created=True,
89+
primary_key=True,
90+
serialize=False,
91+
verbose_name="ID",
92+
),
93+
),
94+
(
95+
"created",
96+
django_extensions.db.fields.CreationDateTimeField(
97+
auto_now_add=True, verbose_name="created"
98+
),
99+
),
100+
(
101+
"modified",
102+
django_extensions.db.fields.ModificationDateTimeField(
103+
auto_now=True, verbose_name="modified"
104+
),
105+
),
106+
("name", models.CharField(max_length=128)),
107+
("syntaxt", models.CharField(max_length=128)),
108+
(
109+
"addons",
110+
models.ForeignKey(
111+
on_delete=django.db.models.deletion.CASCADE,
112+
to="projects.addonsconfig",
113+
),
114+
),
115+
],
116+
options={
117+
"get_latest_by": "modified",
118+
"abstract": False,
119+
},
120+
),
121+
]

readthedocs/projects/models.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,72 @@ def subproject_prefix(self):
123123
return unsafe_join_url_path(prefix, self.alias, "/")
124124

125125

126+
class AddonsConfig(TimeStampedModel):
127+
128+
"""
129+
Addons project configuration.
130+
131+
Store all the configuration for each of the addons.
132+
Everything is enabled by default.
133+
"""
134+
135+
DOC_DIFF_DEFAULT_ROOT_SELECTOR = "[role=main]"
136+
137+
project = models.OneToOneField(
138+
"Project",
139+
related_name="addons",
140+
null=True,
141+
blank=True,
142+
on_delete=models.CASCADE,
143+
)
144+
145+
enabled = models.BooleanField(
146+
default=True,
147+
help_text="Enable/Disable all the addons on this project",
148+
)
149+
150+
# Analytics
151+
analytics_enabled = models.BooleanField(default=True)
152+
153+
# Docdiff
154+
doc_diff_enabled = models.BooleanField(default=True)
155+
doc_diff_show_additions = models.BooleanField(default=True)
156+
doc_diff_show_deletions = models.BooleanField(default=True)
157+
doc_diff_root_selector = models.CharField(null=True, blank=True, max_length=128)
158+
159+
# External version warning
160+
external_version_warning_enabled = models.BooleanField(default=True)
161+
162+
# EthicalAds
163+
ethicalads_enabled = models.BooleanField(default=True)
164+
165+
# Flyout
166+
flyout_enabled = models.BooleanField(default=True)
167+
168+
# Hotkeys
169+
hotkeys_enabled = models.BooleanField(default=True)
170+
171+
# Search
172+
search_enabled = models.BooleanField(default=True)
173+
search_default_filter = models.CharField(null=True, blank=True, max_length=128)
174+
175+
# Stable/Latest version warning
176+
stable_latest_version_warning_enabled = models.BooleanField(default=True)
177+
178+
179+
class AddonSearchFilter(TimeStampedModel):
180+
181+
"""
182+
Addon search user defined filter.
183+
184+
Specific filter defined by the user to show on the search modal.
185+
"""
186+
187+
addons = models.ForeignKey("AddonsConfig", on_delete=models.CASCADE)
188+
name = models.CharField(max_length=128)
189+
syntaxt = models.CharField(max_length=128)
190+
191+
126192
class Project(models.Model):
127193

128194
"""Project model."""

readthedocs/projects/urls/private.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from readthedocs.projects.backends.views import ImportWizardView
1010
from readthedocs.projects.views import private
1111
from readthedocs.projects.views.private import (
12+
AddonsConfigUpdate,
1213
AutomationRuleDelete,
1314
AutomationRuleList,
1415
AutomationRuleMove,
@@ -204,6 +205,18 @@
204205

205206
urlpatterns += domain_urls
206207

208+
# We are allowing users to enable the new beta addons only from the new dashboard
209+
if settings.RTD_EXT_THEME_ENABLED:
210+
addons_urls = [
211+
re_path(
212+
r"^(?P<project_slug>[-\w]+)/addons/edit/$$",
213+
AddonsConfigUpdate.as_view(),
214+
name="projects_addons",
215+
),
216+
]
217+
218+
urlpatterns += addons_urls
219+
207220
integration_urls = [
208221
re_path(
209222
r"^(?P<project_slug>{project_slug})/integrations/$".format(**pattern_opts),

readthedocs/projects/views/private.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from readthedocs.oauth.utils import update_webhook
4747
from readthedocs.projects.filters import ProjectListFilterSet
4848
from readthedocs.projects.forms import (
49+
AddonsConfigForm,
4950
DomainForm,
5051
EmailHookForm,
5152
EnvironmentVariableForm,
@@ -168,6 +169,15 @@ def get_success_url(self):
168169
return reverse('projects_detail', args=[self.object.slug])
169170

170171

172+
class AddonsConfigUpdate(ProjectAdminMixin, PrivateViewMixin, CreateView, UpdateView):
173+
form_class = AddonsConfigForm
174+
success_message = _("Project addons updated")
175+
template_name = "projects/addons_form.html"
176+
177+
def get_success_url(self):
178+
return reverse("projects_addons", args=[self.object.project.slug])
179+
180+
171181
class ProjectDelete(UpdateChangeReasonPostView, ProjectMixin, DeleteView):
172182

173183
success_message = _('Project deleted')

readthedocs/proxito/middleware.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
unresolver,
2626
)
2727
from readthedocs.core.utils import get_cache_tag
28+
from readthedocs.projects.models import Project
2829
from readthedocs.proxito.cache import add_cache_tags, cache_response, private_response
2930
from readthedocs.proxito.redirects import redirect_to_https
3031

@@ -265,16 +266,42 @@ def process_request(self, request): # noqa
265266
return None
266267

267268
def add_hosting_integrations_headers(self, request, response):
269+
"""
270+
Add HTTP headers to communicate to Cloudflare Workers.
271+
272+
We have configured Cloudflare Workers to inject the addons and remove
273+
the old flyout integration based on HTTP headers.
274+
This method uses two different headers for these purposes:
275+
276+
- ``X-RTD-Hosting-Integrations``: inject ``readthedocs-addons.js`` to enable addons.
277+
Enabled by default on projects using ``build.commands``.
278+
- ``X-RTD-Force-Addons``: inject ``readthedocs-addons.js``
279+
and remove old flyout integration (via ``readthedocs-doc-embed.js``).
280+
Enabled only on projects that opted-in via the admin settings.
281+
282+
Note these headers will not be required anymore eventually
283+
since all the project will be using the new addons once we fully roll them out.
284+
"""
268285
addons = False
269286
project_slug = getattr(request, "path_project_slug", "")
270287
version_slug = getattr(request, "path_version_slug", "")
271288

272-
if project_slug and version_slug:
273-
addons = Version.objects.filter(
274-
project__slug=project_slug,
275-
slug=version_slug,
276-
addons=True,
289+
if project_slug:
290+
force_addons = Project.objects.filter(
291+
slug=project_slug,
292+
addons__enabled=True,
277293
).exists()
294+
if force_addons:
295+
response["X-RTD-Force-Addons"] = "true"
296+
return
297+
298+
if version_slug:
299+
addons = Version.objects.filter(
300+
project__slug=project_slug,
301+
slug=version_slug,
302+
addons=True,
303+
).exists()
304+
278305
if addons:
279306
response["X-RTD-Hosting-Integrations"] = "true"
280307

readthedocs/proxito/views/hosting.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ def _v0(self, project, version, build, filename, user):
239239
project.translations.all().only("language").order_by("language")
240240
)
241241
# Make one DB query here and then check on Python code
242+
# TODO: make usage of ``Project.addons.<name>_enabled`` to decide if enabled
242243
project_features = project.features.all().values_list("feature_id", flat=True)
243244

244245
data = {

0 commit comments

Comments
 (0)