Skip to content
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

[Fix #4576] Do not delete projects which have multiple users #4577

Merged
merged 4 commits into from
Aug 29, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions readthedocs/core/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from django.dispatch import receiver
from future.backports.urllib.parse import urlparse

from readthedocs.oauth.models import RemoteOrganization
from readthedocs.projects.models import Project, Domain

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -79,12 +80,17 @@ def decide_if_cors(sender, request, **kwargs): # pylint: disable=unused-argumen
def delete_projects_and_organizations(sender, instance, *args, **kwargs):
# Here we count the owner list from the projects that the user own
# Then exclude the projects where there are more than one owner
projects = instance.projects.all().annotate(num_users=Count('users')).exclude(num_users__gt=1)
# Add annotate before filter
# https://github.com/rtfd/readthedocs.org/pull/4577
# https://docs.djangoproject.com/en/2.1/topics/db/aggregation/#order-of-annotate-and-filter-clauses # noqa
projects = (Project.objects.annotate(num_users=Count('users')).filter(users=instance.id)
.exclude(num_users__gt=1))
Copy link
Contributor

Choose a reason for hiding this comment

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

I ran this query and it seems correct to me.


# Here we count the users list from the organization that the user belong
# Then exclude the organizations where there are more than one user
oauth_organizations = (instance.oauth_organizations.annotate(num_users=Count('users'))
.exclude(num_users__gt=1))
oauth_organizations = (RemoteOrganization.objects.annotate(num_users=Count('users'))
.filter(users=instance.id)
.exclude(num_users__gt=1))
Copy link
Contributor

Choose a reason for hiding this comment

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

These changes should conform to our styleguide by running pre-commit on the file

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 it does not break PEP8 styleguide. Can you explain how it would change?

Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps a link to the style guide is warranted. I don't know of any RTD style guide outside of what the tests do. I searched our docs but I came up empty.

Copy link
Member

Choose a reason for hiding this comment

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

We have the style guide inside pre-commit p:

Copy link
Contributor

Choose a reason for hiding this comment

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

Yup, no explicit styleguide. it's all enforced by pre-commit via yapf and friends. In this specific case, we opt for no right side alignment of variables and arguments. Both are pep8 compliant, but right side alignment is too unreadable and annoying to maintain.

Copy link
Contributor

Choose a reason for hiding this comment

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


projects.delete()
oauth_organizations.delete()
Expand Down
Empty file.
56 changes: 56 additions & 0 deletions readthedocs/core/tests/test_signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import pytest
import django_dynamic_fixture

from django.contrib.auth.models import User

from readthedocs.oauth.models import RemoteOrganization
from readthedocs.projects.models import Project


@pytest.mark.django_db
class TestProjectOrganizationSignal(object):
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm mostly certain we have tests for both of these cases already. Can you provide test output of these tests without your patch applied?

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 missed adding tests while implementing the feature for both of the case.
Here is the output without applying my patch

=============================================================================== FAILURES ===============================================================================
______________________________________ TestProjectOrganizationSignal.test_multiple_users_project_organization_not_delete[Project] ______________________________________

self = <readthedocs.core.tests.test_signals.TestProjectOrganizationSignal object at 0x10eb85d30>, model_class = <class 'readthedocs.projects.models.Project'>

    @pytest.mark.parametrize('model_class', [Project, RemoteOrganization])
    def test_multiple_users_project_organization_not_delete(self, model_class):
        """
            Check Project or RemoteOrganization which have multiple users do not get deleted
            when any of the user delete his account.
            """
    
        obj = django_dynamic_fixture.get(model_class)
        user1 = django_dynamic_fixture.get(User)
        user2 = django_dynamic_fixture.get(User)
        obj.users.add(user1, user2)
    
        obj.refresh_from_db()
        assert obj.users.all().count() > 1
        # Delete 1 user of the project
        user1.delete()
    
        # The project should still exist and it should have 1 user
>       obj.refresh_from_db()

core/tests/test_signals.py:52: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../.tox/py36/lib/python3.6/site-packages/django/db/models/base.py:592: in refresh_from_db
    db_instance = db_instance_qs.get()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = [], args = (), kwargs = {}, clone = [], num = 0

    def get(self, *args, **kwargs):
        """
            Performs the query and returns a single object matching the given
            keyword arguments.
            """
        clone = self.filter(*args, **kwargs)
        if self.query.can_filter() and not self.query.distinct_fields:
            clone = clone.order_by()
        num = len(clone)
        if num == 1:
            return clone._result_cache[0]
        if not num:
            raise self.model.DoesNotExist(
                "%s matching query does not exist." %
>               self.model._meta.object_name
            )
E           readthedocs.projects.models.DoesNotExist: Project matching query does not exist.

../.tox/py36/lib/python3.6/site-packages/django/db/models/query.py:387: DoesNotExist
________________________________ TestProjectOrganizationSignal.test_multiple_users_project_organization_not_delete[RemoteOrganization] _________________________________

self = <readthedocs.core.tests.test_signals.TestProjectOrganizationSignal object at 0x10eb9f9b0>, model_class = <class 'readthedocs.oauth.models.RemoteOrganization'>

    @pytest.mark.parametrize('model_class', [Project, RemoteOrganization])
    def test_multiple_users_project_organization_not_delete(self, model_class):
        """
            Check Project or RemoteOrganization which have multiple users do not get deleted
            when any of the user delete his account.
            """
    
        obj = django_dynamic_fixture.get(model_class)
        user1 = django_dynamic_fixture.get(User)
        user2 = django_dynamic_fixture.get(User)
        obj.users.add(user1, user2)
    
        obj.refresh_from_db()
        assert obj.users.all().count() > 1
        # Delete 1 user of the project
        user1.delete()
    
        # The project should still exist and it should have 1 user
>       obj.refresh_from_db()

core/tests/test_signals.py:52: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../.tox/py36/lib/python3.6/site-packages/django/db/models/base.py:592: in refresh_from_db
    db_instance = db_instance_qs.get()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = [], args = (), kwargs = {}, clone = [], num = 0

    def get(self, *args, **kwargs):
        """
            Performs the query and returns a single object matching the given
            keyword arguments.
            """
        clone = self.filter(*args, **kwargs)
        if self.query.can_filter() and not self.query.distinct_fields:
            clone = clone.order_by()
        num = len(clone)
        if num == 1:
            return clone._result_cache[0]
        if not num:
            raise self.model.DoesNotExist(
                "%s matching query does not exist." %
>               self.model._meta.object_name
            )
E           readthedocs.oauth.models.DoesNotExist: RemoteOrganization matching query does not exist.

../.tox/py36/lib/python3.6/site-packages/django/db/models/query.py:387: DoesNotExist
================================================================= 2 failed, 2 passed in 12.99 seconds ==================================================================
ERROR: InvocationError for command '/Users/safwan/readthedocs/.tox/py36/bin/py.test core -s --reuse-db -vvv' (exited with code 1)

Copy link
Member

Choose a reason for hiding this comment

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

Maybe is worth it to add a test for the delete_account view too

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I am going to add some test on that! but its out of scope of this PR!
will open another PR for that!

Copy link
Contributor

Choose a reason for hiding this comment

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

Great, test output looks good. That's what I'd expect if the delete cascaded to the project


@pytest.mark.parametrize('model_class', [Project, RemoteOrganization])
def test_project_organization_get_deleted_upon_user_delete(self, model_class):
"""
If the user has Project or RemoteOrganization where he is the only user,
upon deleting his account, the Project or RemoteOrganization should also get
deleted.
"""

obj = django_dynamic_fixture.get(model_class)
user1 = django_dynamic_fixture.get(User)
obj.users.add(user1)

obj.refresh_from_db()
assert obj.users.all().count() == 1

# Delete the user
user1.delete()
# The object should not exist
obj = model_class.objects.all().filter(id=obj.id)
assert not obj.exists()

@pytest.mark.parametrize('model_class', [Project, RemoteOrganization])
def test_multiple_users_project_organization_not_delete(self, model_class):
"""
Check Project or RemoteOrganization which have multiple users do not get deleted
when any of the user delete his account.
"""

obj = django_dynamic_fixture.get(model_class)
user1 = django_dynamic_fixture.get(User)
user2 = django_dynamic_fixture.get(User)
obj.users.add(user1, user2)

obj.refresh_from_db()
assert obj.users.all().count() > 1
# Delete 1 user of the project
user1.delete()

# The project should still exist and it should have 1 user
obj.refresh_from_db()
assert obj.id
obj_users = obj.users.all()
assert len(obj_users) == 1
Copy link
Member

Choose a reason for hiding this comment

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

nit: use .count() here.

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 need to take the queryset value in memory eventually. So better to use len() to reduce single COUNT sql query

Copy link
Member

Choose a reason for hiding this comment

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

I wouldn't use len to count queryset elements independently of the context.

assert user2 in obj_users