Skip to content

Allow to change the privacy level of external versions #7825

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Jul 1, 2021
Merged
11 changes: 9 additions & 2 deletions docs/pull-requests.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,15 @@ Privacy levels

Privacy levels are only supported on :doc:`/commercial/index`.

All docs built from a pull requests are private by default.
Currently, this can't be changed, but we are planning to support this.
By default all docs built from pull requests are private.
To change their privacy level:

#. Go to your project dashboard
#. Go to :guilabel:`Admin > Advanced settings`
#. Select your option in :guilabel:`Privacy level of builds from pull requests`
#. Click on :guilabel:`Save`

Privacy levels work the same way as for :ref:`normal versions <versions:privacy levels>`.

Limitations
-----------
Expand Down
49 changes: 43 additions & 6 deletions readthedocs/builds/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from readthedocs.builds.constants import (
BUILD_STATE_FINISHED,
BUILD_STATE_TRIGGERED,
EXTERNAL,
)
from readthedocs.core.utils.extend import SettingsOverrideObject
from readthedocs.projects import constants
Expand Down Expand Up @@ -41,9 +42,27 @@ def _add_from_user_projects(self, queryset, user, admin=False, member=False):
queryset = user_queryset | queryset
return queryset

def public(self, user=None, project=None, only_active=True,
include_hidden=True, only_built=False):
queryset = self.filter(privacy_level=constants.PUBLIC)
def public(
self,
user=None,
project=None,
only_active=True,
include_hidden=True,
only_built=False,
):
"""
Get all allowed versions.

.. note::

External versions use the `Project.external_builds_privacy_level`
field instead of its `privacy_level` field.
"""
queryset = self.filter(privacy_level=constants.PUBLIC).exclude(type=EXTERNAL)
queryset |= self.filter(
type=EXTERNAL,
project__external_builds_privacy_level=constants.PUBLIC,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little worried about performance here -- we're adding a join and a few more queries on all our super common querysets. Can we not just change this on the external manager instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe, I'll need to take a look at where we are using those managers

objects = VersionManager.from_queryset(VersionQuerySet)()
# Only include BRANCH, TAG, UNKNOWN type Versions.
internal = InternalVersionManager.from_queryset(VersionQuerySet)()
# Only include EXTERNAL type Versions.
external = ExternalVersionManager.from_queryset(VersionQuerySet)()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can make this happen only when executing objects and external, as the internal manager already filters them out.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have used a partial class to pass a parameter indicating if the manager being used is for internal only versions.

Copy link
Member

@ericholscher ericholscher Jun 24, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stsewd It seems like it might be cleaner to put this on a public method in the ExternalVersionManager instead of on the base VersionQueryset, since it only applies there (eg. we only want to run it on Version.external.public, I believe).

We should also do the same logic for the other querier (eg. on the ExternalBuildManager)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also want to include this on the default manager, since we do have calls to Version.objects.public/Build.objects.public.
And I'm not sure about moving these to the manager, these are more filters, and all of our querysets are there, we would nee to move all of those. I was about to use inheritance instead of the partial, but then we need to create three more classes (parent, InternalQuerysetBase, InternalQueryset(SeetingsOverride)) and one more override in .com :/

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to worry about filtering external builds on Version.objects.public though right? They will already be filtered out, regardless of privacy level?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I understood what you mean 😅.

  • Version.objects.public -> external and internal versions. Internal versions are checked against version.privacy_level, and external versions are checked against project.external_builds_privacy_level.
  • Version.internal.public -> internal versions only, checked against version.privacy_level
  • Version.external.public -> external versions only, checked against project.external_builds_privacy_level (for this case we could omit one filter from the current implementation, I'll do that)

But anyway, Version.objects.public need to check for both external and internal as that manager is expected to include both types of versions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yea, I was thinking Version.internal.public.

if user:
if user.is_superuser:
queryset = self.all()
Expand Down Expand Up @@ -93,9 +112,27 @@ def _add_from_user_projects(self, queryset, user, admin=False, member=False):
return queryset

def public(self, user=None, project=None):
queryset = self.filter(
version__privacy_level=constants.PUBLIC,
version__project__privacy_level=constants.PUBLIC,
"""
Get all allowed builds.

Builds are public if the linked version and project are public.

.. note::

External versions use the `Project.external_builds_privacy_level`
field instead of its `privacy_level` field.
"""
queryset = (
self.filter(
version__privacy_level=constants.PUBLIC,
version__project__privacy_level=constants.PUBLIC,
)
.exclude(version__type=EXTERNAL)
)
queryset |= self.filter(
version__type=EXTERNAL,
project__external_builds_privacy_level=constants.PUBLIC,
project__privacy_level=constants.PUBLIC,
)
if user:
if user.is_superuser:
Expand Down
8 changes: 5 additions & 3 deletions readthedocs/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,13 @@ class Meta:
per_project_settings = (
'default_version',
'default_branch',
'privacy_level',
'analytics_code',
'analytics_disabled',
'show_version_warning',
'single_version',
'external_builds_enabled',
'privacy_level',
'external_builds_privacy_level',
)
# These that can be set per-version using a config file.
per_version_settings = (
Expand Down Expand Up @@ -235,8 +236,9 @@ def __init__(self, *args, **kwargs):

per_project_settings = list(self.Meta.per_project_settings)
if not settings.ALLOW_PRIVATE_REPOS:
self.fields.pop('privacy_level')
per_project_settings.remove('privacy_level')
for field in ['privacy_level', 'external_builds_privacy_level']:
self.fields.pop(field)
per_project_settings.remove(field)

field_sets = [
Fieldset(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 2.2.17 on 2021-01-12 22:39

from django.db import migrations, models

import readthedocs.projects.models


class Migration(migrations.Migration):

dependencies = [
('projects', '0075_change_mkdocs_name'),
]

operations = [
migrations.AddField(
model_name='project',
name='external_builds_privacy_level',
field=models.CharField(choices=[('public', 'Public'), ('private', 'Private')], default=readthedocs.projects.models.default_privacy_level, help_text='Should builds from pull requests be public?', max_length=20, null=True, verbose_name='Privacy level of Pull Requests'),
),
migrations.AlterField(
model_name='project',
name='external_builds_enabled',
field=models.BooleanField(default=False, help_text='More information in <a href="https://docs.readthedocs.io/page/guides/autobuild-docs-for-pull-requests.html">our docs</a>', verbose_name='Build pull requests for this project'),
),
]
22 changes: 18 additions & 4 deletions readthedocs/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@
log = logging.getLogger(__name__)


def default_privacy_level():
return settings.DEFAULT_PRIVACY_LEVEL


class ProjectRelationship(models.Model):

"""
Expand Down Expand Up @@ -213,10 +217,22 @@ class Project(models.Model):
),
)

# External versions
external_builds_enabled = models.BooleanField(
_('Build pull requests for this project'),
default=False,
help_text=_('More information in <a href="https://docs.readthedocs.io/en/latest/guides/autobuild-docs-for-pull-requests.html">our docs</a>') # noqa
help_text=_('More information in <a href="https://docs.readthedocs.io/page/guides/autobuild-docs-for-pull-requests.html">our docs</a>') # noqa
)
external_builds_privacy_level = models.CharField(
_('Privacy level of Pull Requests'),
max_length=20,
# TODO: remove after migration
null=True,
choices=constants.PRIVACY_CHOICES,
default=default_privacy_level,
help_text=_(
'Should builds from pull requests be public?',
),
)

# Project features
Expand Down Expand Up @@ -433,8 +449,6 @@ def __str__(self):
return self.name

def save(self, *args, **kwargs): # pylint: disable=arguments-differ
from readthedocs.projects import tasks
first_save = self.pk is None
if not self.slug:
# Subdomains can't have underscores in them.
self.slug = slugify(self.name)
Expand Down Expand Up @@ -1162,7 +1176,7 @@ def get_default_branch(self):
return self.vcs_class().fallback_branch

def add_subproject(self, child, alias=None):
subproject, __ = ProjectRelationship.objects.get_or_create(
subproject, _ = ProjectRelationship.objects.get_or_create(
parent=self,
child=child,
alias=alias,
Expand Down
1 change: 1 addition & 0 deletions readthedocs/rtd_tests/tests/test_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def setUp(self):
slug='pip',
repo='https://github.com/rtfd/readthedocs.org',
privacy_level=PUBLIC,
external_builds_privacy_level=PUBLIC,
main_language_project=None,
)
self.pip.versions.update(privacy_level=PUBLIC, built=True)
Expand Down
16 changes: 13 additions & 3 deletions readthedocs/rtd_tests/tests/test_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def setUp(self):
self.project = get(
Project,
privacy_level=PUBLIC,
external_builds_privacy_level=PUBLIC,
users=[self.user],
main_language_project=None,
versions=[],
Expand Down Expand Up @@ -78,6 +79,7 @@ def setUp(self):
self.another_project = get(
Project,
privacy_level=PUBLIC,
external_builds_privacy_level=PUBLIC,
users=[self.another_user],
main_language_project=None,
versions=[],
Expand Down Expand Up @@ -136,6 +138,7 @@ def setUp(self):
self.shared_project = get(
Project,
privacy_level=PUBLIC,
external_builds_privacy_level=PUBLIC,
users=[self.user, self.another_user],
main_language_project=None,
versions=[],
Expand Down Expand Up @@ -280,22 +283,28 @@ def test_all(self):
self.assertEqual(set(query), external_builds)

def test_public(self):
self.shared_project.external_builds_privacy_level = PRIVATE
self.shared_project.save()
query = Build.external.public()
public_external_builds = {
self.build_public_external,
self.build_private_external,
self.another_build_public_external,
self.shared_build_public_external,
self.another_build_private_external,
}
self.assertEqual(query.count(), len(public_external_builds))
self.assertEqual(set(query), public_external_builds)

def test_public_user(self):
self.project.external_builds_privacy_level = PRIVATE
self.project.save()
self.another_project.external_builds_privacy_level = PRIVATE
self.another_project.save()
query = Build.external.public(user=self.user)
builds = {
self.build_private_external,
self.shared_build_private_external,
self.build_public_external,
self.another_build_public_external,
self.shared_build_public_external,
}
self.assertEqual(query.count(), len(builds))
Expand All @@ -311,12 +320,13 @@ def test_public_project(self):
self.assertEqual(set(query), builds)

def test_api(self):
self.another_project.external_builds_privacy_level = PRIVATE
self.another_project.save()
query = Build.external.api(user=self.user)
builds = {
self.build_private_external,
self.shared_build_private_external,
self.build_public_external,
self.another_build_public_external,
self.shared_build_public_external,
}
self.assertEqual(query.count(), len(builds))
Expand Down
1 change: 1 addition & 0 deletions readthedocs/rtd_tests/tests/test_project_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ def test_can_update_privacy_level(self):
'documentation_type': SPHINX,
'python_interpreter': 'python3',
'privacy_level': PRIVATE,
'external_builds_privacy_level': PRIVATE,
},
instance=self.project,
)
Expand Down
47 changes: 42 additions & 5 deletions readthedocs/rtd_tests/tests/test_version_querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def setUp(self):
self.project = get(
Project,
privacy_level=PUBLIC,
external_builds_privacy_level=PUBLIC,
users=[self.user],
main_language_project=None,
versions=[],
Expand All @@ -38,6 +39,7 @@ def setUp(self):
self.another_project = get(
Project,
privacy_level=PUBLIC,
external_builds_privacy_level=PUBLIC,
users=[self.another_user],
main_language_project=None,
versions=[],
Expand All @@ -59,6 +61,7 @@ def setUp(self):
self.shared_project = get(
Project,
privacy_level=PUBLIC,
external_builds_privacy_level=PUBLIC,
users=[self.user, self.another_user],
main_language_project=None,
versions=[],
Expand Down Expand Up @@ -287,22 +290,53 @@ def test_all(self):
self.assertEqual(query.count(), len(versions))
self.assertEqual(set(query), versions)

def test_public(self):
def test_public_with_private_external_versions(self):
self.project.external_builds_privacy_level = PRIVATE
self.project.save()
self.another_project.external_builds_privacy_level = PRIVATE
self.another_project.save()
self.shared_project.external_builds_privacy_level = PRIVATE
self.shared_project.save()
query = Version.external.public()
versions = set()
self.assertEqual(query.count(), len(versions))
self.assertEqual(set(query), versions)

def test_public_with_some_private_external_versions(self):
self.another_project.external_builds_privacy_level = PRIVATE
self.another_project.save()
self.shared_project.external_builds_privacy_level = PRIVATE
self.shared_project.save()
query = Version.external.public()
versions = {
self.external_version_public,
self.another_external_version_public,
self.external_version_private,
}
self.assertEqual(query.count(), len(versions))
self.assertEqual(set(query), versions)

def test_public_with_public_external_versions(self):
query = Version.external.public()
versions = {
self.external_version_public,
self.external_version_private,
self.shared_external_version_public,
self.shared_external_version_private,
self.another_external_version_public,
self.another_external_version_private,
}
self.assertEqual(query.count(), len(versions))
self.assertEqual(set(query), versions)

def test_public_user(self):
self.project.external_builds_privacy_level = PRIVATE
self.project.save()
self.another_project.external_builds_privacy_level = PRIVATE
self.another_project.save()
query = Version.external.public(user=self.user)
versions = {
self.external_version_public,
self.external_version_private,
self.another_external_version_public,
self.shared_external_version_public,
self.shared_external_version_private,
}
Expand All @@ -319,11 +353,14 @@ def test_public_project(self):
self.assertEqual(set(query), versions)

def test_api(self):
self.project.external_builds_privacy_level = PRIVATE
self.project.save()
self.another_project.external_builds_privacy_level = PRIVATE
self.another_project.save()
query = Version.external.api()
versions = {
self.external_version_public,
self.another_external_version_public,
self.shared_external_version_public,
self.shared_external_version_private,
}
self.assertEqual(query.count(), len(versions))
self.assertEqual(set(query), versions)
4 changes: 4 additions & 0 deletions readthedocs/rtd_tests/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ class BuildViewTests(TestCase):
def setUp(self):
self.client.login(username='eric', password='test')
self.pip = Project.objects.get(slug='pip')
self.pip.privacy_level = PUBLIC
self.pip.external_builds_privacy_level = PUBLIC
self.pip.save()
self.pip.versions.update(privacy_level=PUBLIC)

@mock.patch('readthedocs.projects.tasks.update_docs_task')
def test_build_redirect(self, mock):
Expand Down