Skip to content

Commit 763894f

Browse files
authored
Merge pull request #6963 from readthedocs/project-urlconf
Add ability for users to set their own URLConf
2 parents a31ff0f + 77836a8 commit 763894f

File tree

14 files changed

+334
-13
lines changed

14 files changed

+334
-13
lines changed

readthedocs/api/v2/serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class Meta:
2727
'documentation_type',
2828
'users',
2929
'canonical_url',
30+
'urlconf',
3031
)
3132

3233

readthedocs/core/resolver.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def base_resolve_path(
6262
subproject_slug=None,
6363
subdomain=None,
6464
cname=None,
65+
urlconf=None,
6566
):
6667
"""Resolve a with nothing smart, just filling in the blanks."""
6768
# Only support `/docs/project' URLs outside our normal environment. Normally
@@ -79,6 +80,31 @@ def base_resolve_path(
7980
else:
8081
url += '{language}/{version_slug}/{filename}'
8182

83+
# Allow users to override their own URLConf
84+
# This logic could be cleaned up with a standard set of variable replacements
85+
if urlconf:
86+
url = urlconf
87+
url = url.replace(
88+
'$version',
89+
'{version_slug}',
90+
)
91+
url = url.replace(
92+
'$language',
93+
'{language}',
94+
)
95+
url = url.replace(
96+
'$filename',
97+
'{filename}',
98+
)
99+
url = url.replace(
100+
'$subproject',
101+
'{subproject_slug}',
102+
)
103+
if '$' in url:
104+
log.warning(
105+
'Unconverted variable in a resolver URLConf: url=%s', url
106+
)
107+
82108
return url.format(
83109
project_slug=project_slug,
84110
filename=filename,
@@ -97,6 +123,7 @@ def resolve_path(
97123
single_version=None,
98124
subdomain=None,
99125
cname=None,
126+
urlconf=None,
100127
):
101128
"""Resolve a URL with a subset of fields defined."""
102129
version_slug = version_slug or project.get_default_version()
@@ -122,6 +149,7 @@ def resolve_path(
122149
subproject_slug=subproject_slug,
123150
cname=cname,
124151
subdomain=subdomain,
152+
urlconf=urlconf or project.urlconf,
125153
)
126154

127155
def resolve_domain(self, project):

readthedocs/core/static-src/core/js/doc-embed/rtd-data.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,10 @@ function get() {
5959

6060
$.extend(config, defaults, window.READTHEDOCS_DATA);
6161

62-
// Force to use new settings
63-
config.proxied_api_host = '/_';
62+
if (!("proxied_api_host" in config)) {
63+
// Use direct proxied API host
64+
config.proxied_api_host = '/_';
65+
}
6466

6567
return config;
6668
}

readthedocs/core/static/core/js/readthedocs-doc-embed.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

readthedocs/doc_builder/templates/doc_builder/conf.py.tmpl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ context = {
9797
'conf_py_path': '{{ conf_py_path }}',
9898
'api_host': '{{ api_host }}',
9999
'github_user': '{{ github_user }}',
100+
'proxied_api_host': '{{ project.proxied_api_host }}',
100101
'github_repo': '{{ github_repo }}',
101102
'github_version': '{{ github_version }}',
102103
'display_github': {{ display_github }},
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 2.2.10 on 2020-05-26 14:34
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('projects', '0050_migrate_external_builds'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='project',
15+
name='urlconf',
16+
field=models.CharField(default=None, help_text='Supports the following keys: $language, $version, $subproject, $filename. An example: `$language/$version/$filename`.', max_length=255, null=True, verbose_name='Documentation URL Configuration'),
17+
),
18+
]

readthedocs/projects/models.py

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@
1212
from django.core.files.storage import get_storage_class
1313
from django.db import models
1414
from django.db.models import Prefetch
15-
from django.urls import NoReverseMatch, reverse
15+
from django.urls import reverse, re_path
16+
from django.conf.urls import include
1617
from django.utils.functional import cached_property
1718
from django.utils.translation import ugettext_lazy as _
1819
from django_extensions.db.models import TimeStampedModel
20+
from django.views import defaults
1921
from shlex import quote
2022
from taggit.managers import TaggableManager
2123

2224
from readthedocs.api.v2.client import api
2325
from readthedocs.builds.constants import LATEST, STABLE, INTERNAL, EXTERNAL
2426
from readthedocs.core.resolver import resolve, resolve_domain
2527
from readthedocs.core.utils import broadcast, slugify
28+
from readthedocs.constants import pattern_opts
2629
from readthedocs.doc_builder.constants import DOCKER_LIMITS
2730
from readthedocs.projects import constants
2831
from readthedocs.projects.exceptions import ProjectConfigurationError
@@ -44,6 +47,7 @@
4447
from readthedocs.vcs_support.backends import backend_cls
4548
from readthedocs.vcs_support.utils import Lock, NonBlockingLock
4649

50+
4751
from .constants import (
4852
MEDIA_TYPES,
4953
MEDIA_TYPE_PDF,
@@ -53,7 +57,6 @@
5357

5458

5559
log = logging.getLogger(__name__)
56-
DOC_PATH_PREFIX = getattr(settings, 'DOC_PATH_PREFIX', '')
5760

5861

5962
class ProjectRelationship(models.Model):
@@ -201,6 +204,16 @@ class Project(models.Model):
201204
'DirectoryHTMLBuilder">More info on sphinx builders</a>.',
202205
),
203206
)
207+
urlconf = models.CharField(
208+
_('Documentation URL Configuration'),
209+
max_length=255,
210+
default=None,
211+
null=True,
212+
help_text=_(
213+
'Supports the following keys: $language, $version, $subproject, $filename. '
214+
'An example: `$language/$version/$filename`.'
215+
),
216+
)
204217

205218
external_builds_enabled = models.BooleanField(
206219
_('Build pull requests for this project'),
@@ -549,13 +562,117 @@ def get_production_media_url(self, type_, version_slug):
549562

550563
if self.is_subproject:
551564
# docs.example.com/_/downloads/<alias>/<lang>/<ver>/pdf/
552-
path = f'//{domain}/{DOC_PATH_PREFIX}downloads/{self.alias}/{self.language}/{version_slug}/{type_}/' # noqa
565+
path = f'//{domain}/{self.proxied_api_url}downloads/{self.alias}/{self.language}/{version_slug}/{type_}/' # noqa
553566
else:
554567
# docs.example.com/_/downloads/<lang>/<ver>/pdf/
555-
path = f'//{domain}/{DOC_PATH_PREFIX}downloads/{self.language}/{version_slug}/{type_}/'
568+
path = f'//{domain}/{self.proxied_api_url}downloads/{self.language}/{version_slug}/{type_}/' # noqa
556569

557570
return path
558571

572+
@property
573+
def proxied_api_host(self):
574+
"""
575+
Used for the proxied_api_host in javascript.
576+
577+
This needs to start with a slash at the root of the domain,
578+
and end without a slash
579+
"""
580+
if self.urlconf:
581+
# Add our proxied api host at the first place we have a $variable
582+
# This supports both subpaths & normal root hosting
583+
url_prefix = self.urlconf.split('$', 1)[0]
584+
return '/' + url_prefix.strip('/') + '/_'
585+
return '/_'
586+
587+
@property
588+
def proxied_api_url(self):
589+
"""
590+
Like the api_host but for use as a URL prefix.
591+
592+
It can't start with a /, but has to end with one.
593+
"""
594+
return self.proxied_api_host.strip('/') + '/'
595+
596+
@property
597+
def regex_urlconf(self):
598+
"""
599+
Convert User's URLConf into a proper django URLConf.
600+
601+
This replaces the user-facing syntax with the regex syntax.
602+
"""
603+
to_convert = self.urlconf
604+
605+
# We should standardize these names so we can loop over them easier
606+
to_convert = to_convert.replace(
607+
'$version',
608+
'(?P<version_slug>{regex})'.format(regex=pattern_opts['version_slug'])
609+
)
610+
to_convert = to_convert.replace(
611+
'$language',
612+
'(?P<lang_slug>{regex})'.format(regex=pattern_opts['lang_slug'])
613+
)
614+
to_convert = to_convert.replace(
615+
'$filename',
616+
'(?P<filename>{regex})'.format(regex=pattern_opts['filename_slug'])
617+
)
618+
to_convert = to_convert.replace(
619+
'$subproject',
620+
'(?P<subproject_slug>{regex})'.format(regex=pattern_opts['project_slug'])
621+
)
622+
623+
if '$' in to_convert:
624+
log.warning(
625+
'Unconverted variable in a project URLConf: project=%s to_convert=%s',
626+
self, to_convert
627+
)
628+
return to_convert
629+
630+
@property
631+
def proxito_urlconf(self):
632+
"""
633+
Returns a URLConf class that is dynamically inserted via proxito.
634+
635+
It is used for doc serving on projects that have their own ``urlconf``.
636+
"""
637+
from readthedocs.projects.views.public import ProjectDownloadMedia
638+
from readthedocs.proxito.views.serve import ServeDocs
639+
from readthedocs.proxito.views.utils import proxito_404_page_handler
640+
from readthedocs.proxito.urls import core_urls
641+
642+
class ProxitoURLConf:
643+
644+
"""A URLConf dynamically inserted by Proxito."""
645+
646+
proxied_urls = [
647+
re_path(
648+
r'{proxied_api_url}api/v2/'.format(proxied_api_url=self.proxied_api_url),
649+
include('readthedocs.api.v2.proxied_urls'),
650+
name='user_proxied_api'
651+
),
652+
re_path(
653+
r'{proxied_api_url}downloads/'
654+
r'(?P<lang_slug>{lang_slug})/'
655+
r'(?P<version_slug>{version_slug})/'
656+
r'(?P<type_>[-\w]+)/$'.format(
657+
proxied_api_url=self.proxied_api_url,
658+
**pattern_opts),
659+
ProjectDownloadMedia.as_view(same_domain_url=True),
660+
name='user_proxied_downloads'
661+
),
662+
]
663+
docs_urls = [
664+
re_path(
665+
'^{regex_urlconf}'.format(regex_urlconf=self.regex_urlconf),
666+
ServeDocs.as_view(),
667+
name='user_proxied_serve_docs'
668+
),
669+
]
670+
urlpatterns = proxied_urls + core_urls + docs_urls
671+
handler404 = proxito_404_page_handler
672+
handler500 = defaults.server_error
673+
674+
return ProxitoURLConf
675+
559676
@property
560677
def is_subproject(self):
561678
"""Return whether or not this project is a subproject."""

readthedocs/proxito/middleware.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
Additional processing is done to get the project from the URL in the ``views.py`` as well.
77
"""
88
import logging
9+
import sys
910

1011
from django.conf import settings
1112
from django.shortcuts import render
@@ -166,6 +167,28 @@ def process_request(self, request): # noqa
166167
# Otherwise set the slug on the request
167168
request.host_project_slug = request.slug = ret
168169

170+
try:
171+
project = Project.objects.get(slug=request.host_project_slug)
172+
except Project.DoesNotExist:
173+
log.exception('No host_project_slug set on project')
174+
return None
175+
176+
# This is hacky because Django wants a module for the URLConf,
177+
# instead of also accepting string
178+
if project.urlconf:
179+
180+
# Stop Django from caching URLs
181+
project_timestamp = project.modified_date.strftime("%Y%m%d.%H%M%S")
182+
url_key = f'readthedocs.urls.fake.{project.slug}.{project_timestamp}'
183+
184+
log.info(
185+
'Setting URLConf: project=%s url_key=%s urlconf=%s',
186+
project, url_key, project.urlconf,
187+
)
188+
if url_key not in sys.modules:
189+
sys.modules[url_key] = project.proxito_urlconf
190+
request.urlconf = url_key
191+
169192
return None
170193

171194
def process_response(self, request, response): # noqa

0 commit comments

Comments
 (0)