diff --git a/readthedocs/core/filters.py b/readthedocs/core/filters.py new file mode 100644 index 00000000000..a24db416165 --- /dev/null +++ b/readthedocs/core/filters.py @@ -0,0 +1,81 @@ +"""Extended classes for django-filter.""" + +from django_filters import ModelChoiceFilter, views + + +class FilteredModelChoiceFilter(ModelChoiceFilter): + + """ + A model choice field for customizing choice querysets at initialization. + + This extends the model choice field queryset lookup to include executing a + method from the parent filter set. Normally, ModelChoiceFilter will use the + underlying model manager ``all()`` method to populate choices. This of + course is not correct as we need to worry about permissions to organizations + and teams. + + Using a method on the parent filterset, the queryset can be filtered using + attributes on the FilterSet instance, which for now is view time parameters + like ``organization``. + + Additional parameters from this class: + + :param queryset_method: Name of method on parent FilterSet to call to build + queryset for choice population. + :type queryset_method: str + """ + + def __init__(self, *args, **kwargs): + self.queryset_method = kwargs.pop("queryset_method", None) + super().__init__(*args, **kwargs) + + def get_queryset(self, request): + if self.queryset_method: + fn = getattr(self.parent, self.queryset_method, None) + if not callable(fn): + raise ValueError(f"Method {self.queryset_method} is not callable") + return fn() + return super().get_queryset(request) + + +class FilterContextMixin(views.FilterMixin): + + """ + Django-filter filterset mixin class for context data. + + Django-filter gives two classes for constructing views: + + - :py:class:`~django_filters.views.BaseFilterView` + - :py:class:`~django_filters.views.FilterMixin` + + These aren't quite yet usable, as some of our views still support our legacy + dashboard. For now, this class will aim to be an intermediate step. It will + expect these methods to be called from ``get_context_data()``, but will + maintain some level of compatibility with the native mixin/view classes. + """ + + def get_filterset(self, **kwargs): + """ + Construct filterset for view. + + This does not automatically execute like it would with BaseFilterView. + Instead, this should be called directly from ``get_context_data()``. + Unlike the parent methods, this method can be used to pass arguments + directly to the ``FilterSet.__init__``. + + :param kwargs: Arguments to pass to ``FilterSet.__init__`` + """ + # This method overrides the method from FilterMixin with differing + # arguments. We can switch this later if we ever resturcture the view + # pylint: disable=arguments-differ + if not getattr(self, "filterset", None): + filterset_class = self.get_filterset_class() + all_kwargs = self.get_filterset_kwargs(filterset_class) + all_kwargs.update(kwargs) + self.filterset = filterset_class(**all_kwargs) + return self.filterset + + def get_filtered_queryset(self): + if self.filterset.is_valid() or not self.get_strict(): + return self.filterset.qs + return self.filterset.queryset.none() diff --git a/readthedocs/organizations/filters.py b/readthedocs/organizations/filters.py index c8a907386fe..3a9a326db7f 100644 --- a/readthedocs/organizations/filters.py +++ b/readthedocs/organizations/filters.py @@ -1,14 +1,51 @@ -"""Filters used in organization dashboard.""" +"""Filters used in the organization dashboard views.""" import structlog from django.db.models import F -from django.forms.widgets import HiddenInput from django.utils.translation import gettext_lazy as _ -from django_filters import CharFilter, FilterSet, OrderingFilter +from django_filters import ChoiceFilter, FilterSet, OrderingFilter + +from readthedocs.core.filters import FilteredModelChoiceFilter +from readthedocs.organizations.constants import ACCESS_LEVELS +from readthedocs.organizations.models import Organization, Team log = structlog.get_logger(__name__) +class OrganizationFilterSet(FilterSet): + + """ + Organization base filter set. + + Adds some methods that are used for organization related queries and common + base querysets for filter fields. + + Note, the querysets here are also found in the organization base views and + mixin classes. These are redefined here instead of passing in the querysets + from the view. + + :param organization: Organization instance for current view + """ + + def __init__(self, *args, organization=None, **kwargs): + self.organization = organization + super().__init__(*args, **kwargs) + + def get_organization_queryset(self): + return Organization.objects.for_user(user=self.request.user) + + def get_team_queryset(self): + return Team.objects.member( + self.request.user, + organization=self.organization, + ).prefetch_related("organization") + + def is_valid(self): + # This differs from the default logic as we want to consider unbound + # data as a valid filterset state. + return (self.is_bound is False) or self.form.is_valid() + + class OrganizationSortOrderingFilter(OrderingFilter): """Organization list sort ordering django_filters filter.""" @@ -53,13 +90,152 @@ def filter(self, qs, value): return qs.order_by(*order_bys) -class OrganizationListFilterSet(FilterSet): +class OrganizationListFilterSet(OrganizationFilterSet): """Filter and sorting for organization listing page.""" - slug = CharFilter(field_name="slug", widget=HiddenInput) + slug = FilteredModelChoiceFilter( + label=_("Organization"), + empty_label=_("All organizations"), + to_field_name="slug", + queryset_method="get_organization_queryset", + method="get_organization", + ) sort = OrganizationSortOrderingFilter( field_name="sort", label=_("Sort by"), ) + + def get_organization(self, queryset, field_name, organization): + return queryset.filter(slug=organization.slug) + + +class OrganizationProjectListFilterSet(OrganizationFilterSet): + + """ + Filter and sorting set for organization project listing page. + + This filter set creates the following filters in the organization project + listing UI: + + Project + A list of project names that the user has permissions to, using ``slug`` + as a lookup field. This is used when linking directly to a project in + this filter list, and also for quick lookup in the list UI. + + Team + A list of team names that the user has access to, using ``slug`` as a + lookup field. This filter is linked to directly by the team listing + view, as a shortcut for listing projects managed by the team. + """ + + slug = FilteredModelChoiceFilter( + label=_("Project"), + empty_label=_("All projects"), + to_field_name="slug", + queryset_method="get_project_queryset", + method="get_project", + ) + + teams__slug = FilteredModelChoiceFilter( + label=_("Team"), + empty_label=_("All teams"), + field_name="teams", + to_field_name="slug", + queryset_method="get_team_queryset", + ) + + def get_project_queryset(self): + return self.queryset + + def get_project(self, queryset, field_name, project): + return queryset.filter(slug=project.slug) + + +class OrganizationTeamListFilterSet(OrganizationFilterSet): + + """ + Filter and sorting for organization team listing page. + + This filter set creates the following filters in the organization team + listing UI: + + Team + A list of team names that the user has access to, using ``slug`` as a + lookup field. This filter is used mostly for direct linking to a + specific team in the listing UI, but can be used for quick filtering + with the dropdown too. + """ + + slug = FilteredModelChoiceFilter( + label=_("Team"), + empty_label=_("All teams"), + field_name="teams", + to_field_name="slug", + queryset_method="get_team_queryset", + method="get_team", + ) + + def get_team_queryset(self): + return self.queryset + + def get_team(self, queryset, field_name, team): + return queryset.filter(slug=team.slug) + + +class OrganizationTeamMemberListFilterSet(OrganizationFilterSet): + + """ + Filter and sorting set for organization member listing page. + + This filter set's underlying queryset from the member listing view is the + manager method ``Organization.members``. The model described in this filter + is effectively ``User``, but through a union of ``TeamMembers.user`` and + ``Organizations.owners``. + + This filter set will result in the following filters in the UI: + + Team + A list of ``Team`` names, using ``Team.slug`` as the lookup field. This + is linked to directly from the team listing page, to show the users that + are members of a particular team. + + Access + This is an extension of ``Team.access`` in a way, but with a new option + (``ACCESS_OWNER``) to describe ownership privileges through organization + ownership. + + Our modeling is not ideal here, so instead of aiming for model purity and + a confusing UI/UX, this aims for hiding confusing modeling from the user + with clear UI/UX. Otherwise, two competing filters are required for "user + has privileges granted through a team" and "user has privileges granted + through ownership". + """ + + ACCESS_OWNER = "owner" + + teams__slug = FilteredModelChoiceFilter( + label=_("Team"), + empty_label=_("All teams"), + field_name="teams", + to_field_name="slug", + queryset_method="get_team_queryset", + ) + + access = ChoiceFilter( + label=_("Access"), + empty_label=_("All access levels"), + choices=ACCESS_LEVELS + ((ACCESS_OWNER, _("Owner")),), + method="get_access", + ) + + def get_access(self, queryset, field_name, value): + # Note: the queryset here is effectively against the ``User`` model, and + # is from Organization.members, a union of TeamMember.user and + # Organization.owners. + if value == self.ACCESS_OWNER: + return queryset.filter(owner_organizations=self.organization) + if value is not None: + return queryset.filter(teams__access=value) + return queryset diff --git a/readthedocs/organizations/tests/test_filters.py b/readthedocs/organizations/tests/test_filters.py new file mode 100644 index 00000000000..ee3df5a4198 --- /dev/null +++ b/readthedocs/organizations/tests/test_filters.py @@ -0,0 +1,600 @@ +import django_dynamic_fixture as fixture +import pytest +from django.contrib.auth.models import User +from django.urls import reverse +from pytest_django.asserts import assertQuerySetEqual + +from readthedocs.organizations.models import Organization, Team +from readthedocs.projects.models import Project + + +@pytest.mark.django_db +class OrganizationFilterTestCase: + @pytest.fixture(autouse=True) + def set_up(self, settings, client): + settings.RTD_ALLOW_ORGANIZATIONS = True + settings.RTD_EXT_THEME_ENABLED = True + self.client = client + + +@pytest.fixture +def filter_data(request): + users = dict( + user_a=fixture.get(User), + owner_a=fixture.get(User), + user_b=fixture.get(User), + owner_b=fixture.get(User), + ) + + projects = dict( + project_a=fixture.get(Project), + project_b=fixture.get(Project), + ) + + organizations = dict( + org_a=fixture.get( + Organization, + owners=[users["owner_a"]], + projects=[projects["project_a"]], + ), + org_b=fixture.get( + Organization, + owners=[users["owner_b"]], + projects=[projects["project_b"]], + ), + ) + + teams = dict( + team_a=fixture.get( + Team, + access="admin", + organization=organizations["org_a"], + members=[users["user_a"]], + projects=[projects["project_a"]], + ), + team_a_empty=fixture.get( + Team, + access="readonly", + organization=organizations["org_a"], + members=[], + projects=[], + ), + team_b=fixture.get( + Team, + access="admin", + organization=organizations["org_b"], + members=[users["user_b"]], + projects=[projects["project_b"]], + ), + ) + + return dict( + users=users, + projects=projects, + organizations=organizations, + teams=teams, + ) + + +@pytest.fixture +def user(request, filter_data): + return filter_data["users"][request.param] + + +@pytest.fixture +def organization(request, filter_data): + return filter_data["organizations"][request.param] + + +@pytest.fixture +def team(request, filter_data): + return filter_data["teams"][request.param] + + +@pytest.fixture +def teams(request, filter_data): + return [filter_data["teams"][key] for key in request.param] + + +@pytest.fixture +def project(request, filter_data): + return filter_data["projects"][request.param] + + +@pytest.fixture +def users(request, filter_data): + return [filter_data["users"][key] for key in request.param] + + +@pytest.mark.parametrize( + "user,organization", + [ + ("user_a", "org_a"), + ("owner_a", "org_a"), + ("user_b", "org_b"), + ("owner_b", "org_b"), + ], + indirect=True, +) +class TestOrganizationFilterSet(OrganizationFilterTestCase): + def get_filterset_for_user(self, user, organization, data=None, **kwargs): + self.client.force_login(user) + url = reverse("organization_list") + resp = self.client.get(url, data=data) + return resp.context_data.get("filter") + + def test_unfiltered_queryset(self, user, organization): + """No active filters returns full queryset.""" + filter = self.get_filterset_for_user(user, organization) + assertQuerySetEqual( + filter.qs, + [organization], + transform=lambda o: o, + ordered=False, + ) + + def test_filtered_queryset_choice(self, user, organization): + """Valid project choice returns expected results.""" + filter = self.get_filterset_for_user( + user, + organization, + data={"slug": organization.slug}, + ) + assert filter.is_valid() + assertQuerySetEqual( + filter.qs, + [organization], + transform=lambda o: o, + ordered=False, + ) + + def test_filtered_queryset_invalid_choice(self, user, organization): + """Invalid project choice returns the original queryset.""" + wrong_organization = fixture.get( + Organization, + owners=[], + projects=[], + teams=[], + ) + filter = self.get_filterset_for_user( + user, + organization, + data={"slug": wrong_organization.slug}, + ) + assert not filter.is_valid() + # Validation will fail, but the full queryset is still returned. This is + # handled differently at the view level however. + assertQuerySetEqual( + filter.qs, + [organization], + transform=lambda o: o, + ordered=False, + ) + + def test_organization_filter_choices(self, user, organization): + filter = self.get_filterset_for_user( + user, + organization, + ) + assert list(dict(filter.filters["slug"].field.choices).keys()) == [ + "", + organization.slug, + ] + + +class TestOrganizationProjectFilterSet(OrganizationFilterTestCase): + def get_filterset_for_user(self, user, organization, data=None, **kwargs): + self.client.force_login(user) + url = reverse("organization_detail", kwargs={"slug": organization.slug}) + resp = self.client.get(url, data=data) + return resp.context_data.get("filter") + + @pytest.mark.parametrize( + "user,organization,project", + [ + ("user_a", "org_a", "project_a"), + ("owner_a", "org_a", "project_a"), + ("user_b", "org_b", "project_b"), + ("owner_b", "org_b", "project_b"), + ], + indirect=True, + ) + def test_unfiltered_queryset(self, user, organization, project): + """No active filters returns full queryset.""" + filter = self.get_filterset_for_user(user, organization) + assertQuerySetEqual( + filter.qs, + [project], + transform=lambda o: o, + ordered=False, + ) + + @pytest.mark.parametrize( + "user,organization,project", + [ + ("user_a", "org_a", "project_a"), + ("owner_a", "org_a", "project_a"), + ("user_b", "org_b", "project_b"), + ("owner_b", "org_b", "project_b"), + ], + indirect=True, + ) + def test_filtered_queryset_project_choice(self, user, organization, project): + """Valid project choice returns expected results.""" + filter = self.get_filterset_for_user( + user, + organization, + data={"slug": project.slug}, + ) + assertQuerySetEqual( + filter.qs, + [project], + transform=lambda o: o, + ordered=False, + ) + + @pytest.mark.parametrize( + "user,organization,project", + [ + ("user_a", "org_a", "project_a"), + ("owner_a", "org_a", "project_a"), + ("user_b", "org_b", "project_b"), + ("owner_b", "org_b", "project_b"), + ], + indirect=True, + ) + def test_filtered_queryset_project_invalid_choice( + self, user, organization, project + ): + """Invalid project choice returns the original queryset.""" + wrong_project = fixture.get(Project) + filter = self.get_filterset_for_user( + user, + organization, + data={"slug": wrong_project.slug}, + ) + assert not filter.is_valid() + # The full queryset is still returned when a filterset is invalid + assertQuerySetEqual( + filter.qs, + [project], + transform=lambda o: o, + ordered=False, + ) + + @pytest.mark.parametrize( + "user,organization,team,projects", + [ + ("user_a", "org_a", "team_a", ["project_a"]), + ("owner_a", "org_a", "team_a", ["project_a"]), + ("user_a", "org_a", "team_a_empty", ["project_a"]), + ("owner_a", "org_a", "team_a_empty", []), + ("user_b", "org_b", "team_b", ["project_b"]), + ("owner_b", "org_b", "team_b", ["project_b"]), + ], + indirect=["user", "organization", "team"], + ) + def test_filtered_queryset_team_choice( + self, user, organization, team, projects, filter_data + ): + """Valid team choice returns expected results.""" + filter = self.get_filterset_for_user( + user, + organization, + data={"teams__slug": team.slug}, + ) + assertQuerySetEqual( + filter.qs, + [filter_data["projects"][key] for key in projects], + transform=lambda o: o, + ordered=False, + ) + + @pytest.mark.parametrize( + "user,organization,project", + [ + ("user_a", "org_a", "project_a"), + ("owner_a", "org_a", "project_a"), + ("user_b", "org_b", "project_b"), + ("owner_b", "org_b", "project_b"), + ], + indirect=True, + ) + def test_filtered_queryset_team_invalid_choice(self, user, organization, project): + """Invalid team choice returns the original queryset.""" + wrong_team = fixture.get(Team) + filter = self.get_filterset_for_user( + user, + organization, + data={"teams__slug": wrong_team.slug}, + ) + assert not filter.is_valid() + # By default, invalid filtersets return the original queryset + assertQuerySetEqual( + filter.qs, + [project], + transform=lambda o: o, + ordered=False, + ) + + @pytest.mark.parametrize( + "user,organization,project", + [ + ("user_a", "org_a", "project_a"), + ("owner_a", "org_a", "project_a"), + ("user_b", "org_b", "project_b"), + ("owner_b", "org_b", "project_b"), + ], + indirect=True, + ) + def test_project_filter_choices(self, user, organization, project): + """Project filter choices limited to organization projects.""" + filter = self.get_filterset_for_user( + user, + organization, + ) + assert list(dict(filter.filters["slug"].field.choices).keys()) == [ + "", + project.slug, + ] + + @pytest.mark.parametrize( + "user,organization,teams", + [ + ("user_a", "org_a", ["team_a"]), + ("owner_a", "org_a", ["team_a", "team_a_empty"]), + ("user_b", "org_b", ["team_b"]), + ("owner_b", "org_b", ["team_b"]), + ], + indirect=["user", "organization"], + ) + def test_team_filter_choices(self, user, organization, teams, filter_data): + """Team filter choices limited to organization teams.""" + filter = self.get_filterset_for_user( + user, + organization, + ) + choices = [filter_data["teams"][key].slug for key in teams] + choices.insert(0, "") + assert list(dict(filter.filters["teams__slug"].field.choices).keys()) == choices + + +class TestOrganizationTeamListFilterSet(OrganizationFilterTestCase): + def get_filterset_for_user(self, user, organization, data=None, **kwargs): + self.client.force_login(user) + url = reverse("organization_team_list", kwargs={"slug": organization.slug}) + resp = self.client.get(url, data=data) + return resp.context_data.get("filter") + + @pytest.mark.parametrize( + "user,organization,teams", + [ + ("user_a", "org_a", ["team_a"]), + ("owner_a", "org_a", ["team_a", "team_a_empty"]), + ("user_b", "org_b", ["team_b"]), + ("owner_b", "org_b", ["team_b"]), + ], + indirect=True, + ) + def test_unfiltered_queryset(self, user, organization, teams): + """No active filters returns full queryset.""" + filter = self.get_filterset_for_user( + user, + organization, + ) + assertQuerySetEqual( + filter.qs, + teams, + transform=lambda o: o, + ordered=False, + ) + + @pytest.mark.parametrize( + "user,organization,team", + [ + ("user_a", "org_a", "team_a"), + ("owner_a", "org_a", "team_a"), + ("owner_a", "org_a", "team_a_empty"), + ("user_b", "org_b", "team_b"), + ("owner_b", "org_b", "team_b"), + ], + indirect=True, + ) + def test_filtered_queryset_team_choice(self, user, organization, team): + """Valid team choice returns expected results.""" + filter = self.get_filterset_for_user( + user, + organization, + data={"slug": team.slug}, + ) + assertQuerySetEqual( + filter.qs, + [team], + transform=lambda o: o, + ordered=False, + ) + + @pytest.mark.parametrize( + "user,organization,team", + [ + ("user_a", "org_a", "team_a"), + ("owner_a", "org_a", "team_a"), + ("user_b", "org_b", "team_b"), + ("owner_b", "org_b", "team_b"), + ], + indirect=True, + ) + def test_filtered_queryset_team_invalid_choice(self, user, organization, team): + """Invalid team choice returns the original queryset.""" + wrong_team = fixture.get(Team) + filter = self.get_filterset_for_user( + user, + organization, + {"slug": wrong_team.slug}, + ) + assert not filter.is_valid() + + @pytest.mark.parametrize( + "user,organization,teams", + [ + ("user_a", "org_a", ["team_a"]), + ("owner_a", "org_a", ["team_a", "team_a_empty"]), + ("user_b", "org_b", ["team_b"]), + ("owner_b", "org_b", ["team_b"]), + ], + indirect=True, + ) + def test_team_filter_choices(self, user, organization, teams): + """Team filter choices limited to organization teams.""" + filter = self.get_filterset_for_user( + user, + organization, + ) + choices = [team.slug for team in teams] + choices.insert(0, "") + assert list(dict(filter.filters["slug"].field.choices).keys()) == choices + + +class TestOrganizationTeamMemberFilterSet(OrganizationFilterTestCase): + def get_filterset_for_user(self, user, organization, data=None, **kwargs): + self.client.force_login(user) + url = reverse("organization_members", kwargs={"slug": organization.slug}) + resp = self.client.get(url, data=data) + return resp.context_data.get("filter") + + @pytest.mark.parametrize( + "user,organization,users", + [ + ("user_a", "org_a", ["user_a", "owner_a"]), + ("owner_a", "org_a", ["user_a", "owner_a"]), + ("user_b", "org_b", ["user_b", "owner_b"]), + ("owner_b", "org_b", ["user_b", "owner_b"]), + ], + indirect=True, + ) + def test_unfiltered_queryset(self, user, organization, users): + """No active filters returns full queryset.""" + filter = self.get_filterset_for_user( + user, + organization, + ) + assertQuerySetEqual( + filter.qs, + users, + transform=lambda o: o, + ordered=False, + ) + + @pytest.mark.parametrize( + "user,organization,team,users", + [ + ("user_a", "org_a", "team_a", ["user_a"]), + ("owner_a", "org_a", "team_a", ["user_a"]), + ("owner_a", "org_a", "team_a_empty", []), + ("user_b", "org_b", "team_b", ["user_b"]), + ("owner_b", "org_b", "team_b", ["user_b"]), + ], + indirect=True, + ) + def test_filtered_queryset_team_choice(self, user, organization, team, users): + """Valid team choice returns expected results.""" + filter = self.get_filterset_for_user( + user, + organization, + data={"teams__slug": team.slug}, + ) + assertQuerySetEqual( + filter.qs, + users, + transform=lambda o: o, + ordered=False, + ) + + @pytest.mark.parametrize( + "user,organization,access,users", + [ + ("user_a", "org_a", "readonly", []), + ("user_a", "org_a", "admin", ["user_a"]), + ("user_a", "org_a", "owner", ["owner_a"]), + ("owner_a", "org_a", "readonly", []), + ("owner_a", "org_a", "admin", ["user_a"]), + ("owner_a", "org_a", "owner", ["owner_a"]), + ], + indirect=["user", "organization", "users"], + ) + def test_filtered_queryset_access_choice(self, user, organization, access, users): + """Valid access choice returns expected results.""" + filter = self.get_filterset_for_user( + user, + organization, + data={"access": access}, + ) + assertQuerySetEqual( + filter.qs, + users, + transform=lambda o: o, + ordered=False, + ) + + @pytest.mark.parametrize( + "user,organization", + [ + ("user_a", "org_a"), + ("owner_a", "org_a"), + ("user_b", "org_b"), + ("owner_b", "org_b"), + ], + indirect=True, + ) + def test_filtered_queryset_team_invalid_choice(self, user, organization): + """Invalid team choice returns the original queryset.""" + wrong_team = fixture.get(Team) + filter = self.get_filterset_for_user( + user, + organization, + data={"teams__slug": wrong_team.slug}, + ) + assert not filter.is_valid() + + @pytest.mark.parametrize( + "user,organization,teams", + [ + ("user_a", "org_a", ["team_a"]), + ("owner_a", "org_a", ["team_a", "team_a_empty"]), + ("user_b", "org_b", ["team_b"]), + ("owner_b", "org_b", ["team_b"]), + ], + indirect=True, + ) + def test_team_filter_choices(self, user, organization, teams): + """Team filter choices limited to organization teams with permisisons.""" + filter = self.get_filterset_for_user( + user, + organization=organization, + ) + choices = [team.slug for team in teams] + choices.insert(0, "") + assert list(dict(filter.filters["teams__slug"].field.choices).keys()) == choices + + @pytest.mark.parametrize( + "user,organization", + [ + ("user_a", "org_a"), + ("owner_a", "org_a"), + ("user_b", "org_b"), + ("owner_b", "org_b"), + ], + indirect=True, + ) + def test_access_filter_choices(self, user, organization): + """Access filter choices are correct.""" + filter = self.get_filterset_for_user( + user, + organization, + ) + assert list(dict(filter.filters["access"].field.choices).keys()) == [ + "", + "readonly", + "admin", + "owner", + ] diff --git a/readthedocs/organizations/views/private.py b/readthedocs/organizations/views/private.py index 0eeb5c7ec25..fce01a8207a 100644 --- a/readthedocs/organizations/views/private.py +++ b/readthedocs/organizations/views/private.py @@ -13,6 +13,7 @@ from readthedocs.audit.filters import OrganizationSecurityLogFilter from readthedocs.audit.models import AuditLog +from readthedocs.core.filters import FilterContextMixin from readthedocs.core.history import UpdateChangeReasonPostView from readthedocs.core.mixins import PrivateViewMixin from readthedocs.invitations.models import Invitation @@ -64,21 +65,23 @@ def get_success_url(self): ) -class ListOrganization(PrivateViewMixin, OrganizationView, ListView): - template_name = 'organizations/organization_list.html' +class ListOrganization( + FilterContextMixin, PrivateViewMixin, OrganizationView, ListView +): + template_name = "organizations/organization_list.html" admin_only = False + filterset_class = OrganizationListFilterSet + strict = True # Return an empty queryset on filter validation errors + def get_queryset(self): return Organization.objects.for_user(user=self.request.user) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) if settings.RTD_EXT_THEME_ENABLED: - filter = OrganizationListFilterSet( - self.request.GET, queryset=self.get_queryset() - ) - context["filter"] = filter - context["organization_list"] = filter.qs + context["filter"] = self.get_filterset() + context["organization_list"] = self.get_filtered_queryset() return context diff --git a/readthedocs/organizations/views/public.py b/readthedocs/organizations/views/public.py index 8582cf188bb..59b07be8de4 100644 --- a/readthedocs/organizations/views/public.py +++ b/readthedocs/organizations/views/public.py @@ -1,11 +1,18 @@ """Views that don't require login.""" # pylint: disable=too-many-ancestors import structlog +from django.conf import settings from django.http import HttpResponseRedirect from django.urls import reverse, reverse_lazy from django.views.generic.base import TemplateView from vanilla import DetailView, GenericView, ListView +from readthedocs.core.filters import FilterContextMixin +from readthedocs.organizations.filters import ( + OrganizationProjectListFilterSet, + OrganizationTeamListFilterSet, + OrganizationTeamMemberListFilterSet, +) from readthedocs.organizations.models import Team from readthedocs.organizations.views.base import ( CheckOrganizationsEnabled, @@ -26,38 +33,63 @@ class OrganizationTemplateView(CheckOrganizationsEnabled, TemplateView): # Organization -class DetailOrganization(OrganizationView, DetailView): + +class DetailOrganization(FilterContextMixin, OrganizationView, DetailView): """Display information about an organization.""" template_name = 'organizations/organization_detail.html' admin_only = False + filterset_class = OrganizationProjectListFilterSet + strict = True + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) org = self.get_object() - context['projects'] = ( + projects = ( Project.objects .for_user(self.request.user) .filter(organizations=org) .all() ) - context['teams'] = ( - Team.objects - .member(self.request.user, organization=org) - .prefetch_related('organization') - .all() - ) - context['owners'] = org.owners.all() + if settings.RTD_EXT_THEME_ENABLED: + context["filter"] = self.get_filterset( + queryset=projects, + organization=org, + ) + projects = self.get_filtered_queryset() + else: + teams = ( + Team.objects.member(self.request.user, organization=org) + .prefetch_related("organization") + .all() + ) + context["teams"] = teams + context["owners"] = org.owners.all() + + context["projects"] = projects return context # Member Views -class ListOrganizationMembers(OrganizationMixin, ListView): - template_name = 'organizations/member_list.html' - context_object_name = 'members' +class ListOrganizationMembers(FilterContextMixin, OrganizationMixin, ListView): + template_name = "organizations/member_list.html" + context_object_name = "members" admin_only = False + filterset_class = OrganizationTeamMemberListFilterSet + strict = True + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if settings.RTD_EXT_THEME_ENABLED: + context["filter"] = self.get_filterset( + organization=self.get_organization(), + ) + context[self.get_context_object_name()] = self.get_filtered_queryset() + return context + def get_queryset(self): return self.get_organization().members @@ -69,15 +101,28 @@ def get_success_url(self): # Team Views -class ListOrganizationTeams(OrganizationTeamView, ListView): +class ListOrganizationTeams(FilterContextMixin, OrganizationTeamView, ListView): template_name = 'organizations/team_list.html' context_object_name = 'teams' admin_only = False + filterset_class = OrganizationTeamListFilterSet + strict = True + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) org = self.get_organization() - context['owners'] = org.owners.all() + + if settings.RTD_EXT_THEME_ENABLED: + # TODO the team queryset, used through ``get_queryset()`` defines + # sorting. Sorting should only happen in the filterset, so it can be + # controlled in the UI. + context["filter"] = self.get_filterset( + organization=org, + ) + context[self.get_context_object_name()] = self.get_filtered_queryset() + else: + context["owners"] = org.owners.all() return context @@ -96,7 +141,6 @@ class RedirectRedeemTeamInvitation(CheckOrganizationsEnabled, GenericView): """Redirect invitation links to the new view.""" - # pylint: disable=unused-argument def get(self, request, *args, **kwargs): return HttpResponseRedirect( reverse("invitations_redeem", args=[kwargs["hash"]])