Skip to content

Commit ae67af4

Browse files
authored
Changes and fixes to symlinking and serving (#2611)
* Add symlinking overrides and version manager queryset changes * More changes to the extended modeling * Add some cleanup and more docs * Test filesystem explicitly * Expand test coverage, explicitly handle project/version privacy
1 parent 525b0cd commit ae67af4

File tree

7 files changed

+881
-305
lines changed

7 files changed

+881
-305
lines changed

readthedocs/builds/models.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
from taggit.managers import TaggableManager
1313

1414
from readthedocs.core.utils import broadcast
15-
from readthedocs.privacy.loader import (VersionManager, RelatedBuildManager,
16-
BuildManager)
15+
from readthedocs.privacy.backend import VersionQuerySet, VersionManager
16+
from readthedocs.privacy.loader import RelatedBuildManager, BuildManager
1717
from readthedocs.projects.models import Project
1818
from readthedocs.projects.constants import (PRIVACY_CHOICES, GITHUB_URL,
1919
GITHUB_REGEXS, BITBUCKET_URL,
@@ -69,7 +69,8 @@ class Version(models.Model):
6969
)
7070
tags = TaggableManager(blank=True)
7171
machine = models.BooleanField(_('Machine Created'), default=False)
72-
objects = VersionManager()
72+
73+
objects = VersionManager.from_queryset(VersionQuerySet)()
7374

7475
class Meta:
7576
unique_together = [('project', 'slug')]

readthedocs/core/symlink.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
from django.conf import settings
6262

6363
from readthedocs.builds.models import Version
64+
from readthedocs.core.utils.extend import SettingsOverrideObject
6465
from readthedocs.projects import constants
6566
from readthedocs.projects.models import Domain
6667
from readthedocs.projects.utils import run
@@ -293,7 +294,7 @@ def get_default_version(self):
293294
return None
294295

295296

296-
class PublicSymlink(Symlink):
297+
class PublicSymlinkBase(Symlink):
297298
CNAME_ROOT = os.path.join(settings.SITE_ROOT, 'public_cname_root')
298299
WEB_ROOT = os.path.join(settings.SITE_ROOT, 'public_web_root')
299300
PROJECT_CNAME_ROOT = os.path.join(settings.SITE_ROOT, 'public_cname_project')
@@ -309,7 +310,7 @@ def get_translations(self):
309310
return self.project.translations.protected()
310311

311312

312-
class PrivateSymlink(Symlink):
313+
class PrivateSymlinkBase(Symlink):
313314
CNAME_ROOT = os.path.join(settings.SITE_ROOT, 'private_cname_root')
314315
WEB_ROOT = os.path.join(settings.SITE_ROOT, 'private_web_root')
315316
PROJECT_CNAME_ROOT = os.path.join(settings.SITE_ROOT, 'private_cname_project')
@@ -323,3 +324,13 @@ def get_subprojects(self):
323324

324325
def get_translations(self):
325326
return self.project.translations.private()
327+
328+
329+
class PublicSymlink(SettingsOverrideObject):
330+
331+
_default_class = PublicSymlinkBase
332+
333+
334+
class PrivateSymlink(SettingsOverrideObject):
335+
336+
_default_class = PrivateSymlinkBase

readthedocs/core/utils/extend.py

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,51 +4,72 @@
44

55
from django.conf import settings
66
from django.utils.module_loading import import_by_path
7-
from django.utils.functional import LazyObject
87

98

10-
class SettingsOverrideObject(LazyObject):
9+
def get_override_class(proxy_class, default_class=None):
10+
"""Determine which class to use in an override class
11+
12+
The `proxy_class` is the main class that is used, and `default_class` is the
13+
default class that this proxy class will instantiate. If `default_class` is
14+
not defined, this will be inferred from the `proxy_class`, as is defined in
15+
:py:cls:`SettingsOverrideObject`.
16+
"""
17+
if default_class is None:
18+
default_class = getattr(proxy_class, '_default_class')
19+
class_id = '.'.join([
20+
inspect.getmodule(proxy_class).__name__,
21+
proxy_class.__name__
22+
])
23+
class_path = getattr(settings, 'CLASS_OVERRIDES', {}).get(class_id)
24+
if class_path is None and proxy_class._override_setting is not None:
25+
class_path = getattr(settings, proxy_class._override_setting, None)
26+
if class_path is not None:
27+
default_class = import_by_path(class_path)
28+
return default_class
29+
30+
31+
class SettingsOverrideMeta(type):
32+
33+
"""Meta class for passing along classmethod class to the underlying class"""
34+
35+
def __getattr__(cls, attr): # noqa: pep8 false positive
36+
proxy_class = getattr(cls, '_default_class')
37+
return getattr(proxy_class, attr)
38+
39+
40+
class SettingsOverrideObject(object):
1141

1242
"""Base class for creating class that can be overridden
1343
1444
This is used for extension points in the code, where we want to extend a
15-
class without monkey patching it. This abstract class allows for lazy
16-
inheritance, creating a class from the specified class or from a setting,
17-
but only once the class is called.
45+
class without monkey patching it. This class will proxy classmethod calls
46+
and instantiation to an underlying class, determined by used of
47+
:py:cvar:`_default_class` or an override class from settings.
1848
19-
Default to an instance of the class defined by :py:cvar:`_default_class`.
49+
The default target class is defined by :py:cvar:`_default_class`.
2050
21-
Next, look for an override setting class path in
22-
``settings.CLASS_OVERRIDES``, which should be a dictionary of class paths.
23-
The setting should be a dictionary keyed by the object path name::
51+
To override this class, an override setting class path can be added to
52+
``settings.CLASS_OVERRIDES``. This settings should be a dictionary keyed by
53+
source class paths, with values to the override classes::
2454
2555
CLASS_OVERRIDES = {
2656
'readthedocs.core.resolver.Resolver': 'something.resolver.Resolver',
2757
}
2858
2959
Lastly, if ``settings.CLASS_OVERRIDES`` is missing, or the key is not found,
30-
attempt to pull the key :py:cvar:`_override_setting` from ``settings``.
60+
attempt to pull the key :py:cvar:`_override_setting` from ``settings``. This
61+
matches the pattern we've been using previously.
3162
"""
3263

64+
__metaclass__ = SettingsOverrideMeta
65+
3366
_default_class = None
3467
_override_setting = None
3568

36-
def _setup(self):
69+
def __new__(cls, *args, **kwargs):
3770
"""Set up wrapped object
3871
39-
This is called when attributes are accessed on :py:class:`LazyObject`
40-
and the underlying wrapped object does not yet exist.
72+
Create an instance of the underlying target class and return instead of
73+
this class.
4174
"""
42-
cls = self._default_class
43-
cls_path = (getattr(settings, 'CLASS_OVERRIDES', {})
44-
.get(self._get_class_id()))
45-
if cls_path is None and self._override_setting is not None:
46-
cls_path = getattr(settings, self._override_setting, None)
47-
if cls_path is not None:
48-
cls = import_by_path(cls_path)
49-
self._wrapped = cls()
50-
51-
def _get_class_id(self):
52-
# type() here, because LazyObject overrides some attribute access
53-
return '.'.join([inspect.getmodule(type(self)).__name__,
54-
type(self).__name__])
75+
return get_override_class(cls, cls._default_class)(*args, **kwargs)

readthedocs/privacy/backend.py

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
from django.db import models
32

43
from guardian.shortcuts import get_objects_for_user
@@ -9,6 +8,8 @@
98
from readthedocs.builds.constants import LATEST_VERBOSE_NAME
109
from readthedocs.builds.constants import STABLE
1110
from readthedocs.builds.constants import STABLE_VERBOSE_NAME
11+
from readthedocs.core.utils.extend import (SettingsOverrideObject,
12+
get_override_class)
1213
from readthedocs.projects import constants
1314

1415

@@ -75,6 +76,50 @@ def api(self, user=None):
7576

7677
class VersionManager(models.Manager):
7778

79+
"""Version manager for manager only queries
80+
81+
For queries not suitable for the :py:cls:`VersionQuerySet`, such as create
82+
queries.
83+
"""
84+
85+
@classmethod
86+
def from_queryset(cls, queryset_class, class_name=None):
87+
# This is overridden because :py:meth:`models.Manager.from_queryset`
88+
# uses `inspect` to retrieve the class methods, and the proxy class has
89+
# no direct members.
90+
queryset_class = get_override_class(
91+
VersionQuerySet,
92+
VersionQuerySet._default_class
93+
)
94+
return super(VersionManager, cls).from_queryset(queryset_class, class_name)
95+
96+
def create_stable(self, **kwargs):
97+
defaults = {
98+
'slug': STABLE,
99+
'verbose_name': STABLE_VERBOSE_NAME,
100+
'machine': True,
101+
'active': True,
102+
'identifier': STABLE,
103+
'type': TAG,
104+
}
105+
defaults.update(kwargs)
106+
return self.create(**defaults)
107+
108+
def create_latest(self, **kwargs):
109+
defaults = {
110+
'slug': LATEST,
111+
'verbose_name': LATEST_VERBOSE_NAME,
112+
'machine': True,
113+
'active': True,
114+
'identifier': LATEST,
115+
'type': BRANCH,
116+
}
117+
defaults.update(kwargs)
118+
return self.create(**defaults)
119+
120+
121+
class VersionQuerySetBase(models.QuerySet):
122+
78123
"""
79124
Versions take into account their own privacy_level setting.
80125
"""
@@ -83,7 +128,7 @@ class VersionManager(models.Manager):
83128

84129
def _add_user_repos(self, queryset, user):
85130
if user.has_perm('builds.view_version'):
86-
return self.get_queryset().all().distinct()
131+
return self.all().distinct()
87132
if user.is_authenticated():
88133
user_queryset = get_objects_for_user(user, 'builds.view_version')
89134
queryset = user_queryset | queryset
@@ -122,29 +167,17 @@ def private(self, user=None, project=None, only_active=True):
122167
def api(self, user=None):
123168
return self.public(user, only_active=False)
124169

125-
def create_stable(self, **kwargs):
126-
defaults = {
127-
'slug': STABLE,
128-
'verbose_name': STABLE_VERBOSE_NAME,
129-
'machine': True,
130-
'active': True,
131-
'identifier': STABLE,
132-
'type': TAG,
133-
}
134-
defaults.update(kwargs)
135-
return self.create(**defaults)
170+
def for_project(self, project):
171+
"""Return all versions for a project, including translations"""
172+
return self.filter(
173+
models.Q(project=project) |
174+
models.Q(project__main_language_project=project)
175+
)
136176

137-
def create_latest(self, **kwargs):
138-
defaults = {
139-
'slug': LATEST,
140-
'verbose_name': LATEST_VERBOSE_NAME,
141-
'machine': True,
142-
'active': True,
143-
'identifier': LATEST,
144-
'type': BRANCH,
145-
}
146-
defaults.update(kwargs)
147-
return self.create(**defaults)
177+
178+
class VersionQuerySet(SettingsOverrideObject):
179+
180+
_default_class = VersionQuerySetBase
148181

149182

150183
class BuildManager(models.Manager):

readthedocs/privacy/loader.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@
55
ProjectManager = import_by_path(
66
getattr(settings, 'PROJECT_MANAGER',
77
'readthedocs.privacy.backend.ProjectManager'))
8-
VersionManager = import_by_path(
9-
getattr(settings, 'VERSION_MANAGER',
10-
'readthedocs.privacy.backend.VersionManager'))
8+
# VersionQuerySet was replaced by SettingsOverrideObject
119
BuildManager = import_by_path(
1210
getattr(settings, 'BUILD_MANAGER',
1311
'readthedocs.privacy.backend.BuildManager'))

readthedocs/rtd_tests/tests/test_extend.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.test import TestCase, override_settings
22

3-
from readthedocs.core.utils.extend import SettingsOverrideObject
3+
from readthedocs.core.utils.extend import (SettingsOverrideObject,
4+
get_override_class)
45

56

67
# Top level to ensure module name is correct
@@ -29,10 +30,12 @@ class Foo(SettingsOverrideObject):
2930
_override_setting = 'FOO_OVERRIDE_CLASS'
3031

3132
foo = Foo()
32-
self.assertEqual(foo._get_class_id(), EXTEND_PATH)
3333
self.assertEqual(foo.__class__.__name__, 'FooBase')
3434
self.assertEqual(foo.bar(), 1)
3535

36+
override_class = get_override_class(Foo, Foo._default_class)
37+
self.assertEqual(override_class, FooBase)
38+
3639
@override_settings(FOO_OVERRIDE_CLASS=EXTEND_OVERRIDE_PATH)
3740
def test_with_basic_override(self):
3841
"""Test class override setting defined"""
@@ -41,10 +44,12 @@ class Foo(SettingsOverrideObject):
4144
_override_setting = 'FOO_OVERRIDE_CLASS'
4245

4346
foo = Foo()
44-
self.assertEqual(foo._get_class_id(), EXTEND_PATH)
4547
self.assertEqual(foo.__class__.__name__, 'NewFoo')
4648
self.assertEqual(foo.bar(), 2)
4749

50+
override_class = get_override_class(Foo, Foo._default_class)
51+
self.assertEqual(override_class, NewFoo)
52+
4853
@override_settings(FOO_OVERRIDE_CLASS=None,
4954
CLASS_OVERRIDES={
5055
EXTEND_PATH: EXTEND_OVERRIDE_PATH,
@@ -56,10 +61,12 @@ class Foo(SettingsOverrideObject):
5661
_override_setting = 'FOO_OVERRIDE_CLASS'
5762

5863
foo = Foo()
59-
self.assertEqual(foo._get_class_id(), EXTEND_PATH)
6064
self.assertEqual(foo.__class__.__name__, 'NewFoo')
6165
self.assertEqual(foo.bar(), 2)
6266

67+
override_class = get_override_class(Foo, Foo._default_class)
68+
self.assertEqual(override_class, NewFoo)
69+
6370
@override_settings(FOO_OVERRIDE_CLASS=None,
6471
CLASS_OVERRIDES={
6572
EXTEND_PATH: EXTEND_OVERRIDE_PATH,
@@ -70,6 +77,8 @@ class Foo(SettingsOverrideObject):
7077
_default_class = FooBase
7178

7279
foo = Foo()
73-
self.assertEqual(foo._get_class_id(), EXTEND_PATH)
7480
self.assertEqual(foo.__class__.__name__, 'NewFoo')
7581
self.assertEqual(foo.bar(), 2)
82+
83+
override_class = get_override_class(Foo, Foo._default_class)
84+
self.assertEqual(override_class, NewFoo)

0 commit comments

Comments
 (0)