Skip to content

Add organization view UI filters #10847

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 10 commits into from
Nov 1, 2023
242 changes: 238 additions & 4 deletions readthedocs/organizations/filters.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
"""Filters used in organization dashboard."""
"""
Filters used in the organization dashboard.

Some of the below filters instantiate with multiple querysets as django-filter
doesn't use the supplied queryset for filter field choices. By default the
``all()`` manager method is used from the filter field model. ``FilterSet`` is
mostly instantiated at class definition time, not per-instance, so we need to
pass in related querysets at view time.
"""

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,
ModelChoiceFilter,
OrderingFilter,
)

from readthedocs.organizations.constants import ACCESS_LEVELS
from readthedocs.organizations.models import Organization, Team
from readthedocs.projects.models import Project

log = structlog.get_logger(__name__)

Expand Down Expand Up @@ -57,9 +73,227 @@ class OrganizationListFilterSet(FilterSet):

"""Filter and sorting for organization listing page."""

slug = CharFilter(field_name="slug", widget=HiddenInput)
slug = ModelChoiceFilter(
label=_("Organization"),
empty_label=_("All organizations"),
to_field_name="slug",
# Queryset is required, give an empty queryset from the correct model
queryset=Organization.objects.none(),
method="get_organization",
)

sort = OrganizationSortOrderingFilter(
field_name="sort",
label=_("Sort by"),
)

def __init__(
self,
data=None,
queryset=None,
*,
request=None,
prefix=None,
):
super().__init__(data, queryset, request=request, prefix=prefix)
# Redefine the querysets used for the filter fields using the querysets
# defined at view time. This populates the filter field with only the
# correct related objects for the user. Otherwise, the default for model
# choice filter fields is ``<Model>.objects.all()``.
self.filters["slug"].field.queryset = self.queryset.all()

def get_organization(self, queryset, field_name, organization):
return queryset.filter(slug=organization.slug)


class OrganizationProjectListFilterSet(FilterSet):

"""
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.

This filter set takes an additional argument, used to limit the model choice
filter field values:

:param team_queryset: Organization team list queryset
"""

slug = ModelChoiceFilter(
label=_("Project"),
empty_label=_("All projects"),
to_field_name="slug",
# Queryset is required, give an empty queryset from the correct model
queryset=Project.objects.none(),
method="get_project",
)

teams__slug = ModelChoiceFilter(
label=_("Team"),
empty_label=_("All teams"),
field_name="teams",
to_field_name="slug",
# Queryset is required, give an empty queryset from the correct model
queryset=Team.objects.none(),
)

def __init__(
self,
data=None,
queryset=None,
*,
request=None,
prefix=None,
teams_queryset=None,
):
super().__init__(data, queryset, request=request, prefix=prefix)
# Redefine the querysets used for the filter fields using the querysets
# defined at view time. This populates the filter field with only the
# correct related objects for the user. Otherwise, the default for model
# choice filter fields is ``<Model>.objects.all()``.
self.filters["slug"].field.queryset = self.queryset.all()
self.filters["teams__slug"].field.queryset = teams_queryset.all()

def get_project(self, queryset, field_name, project):
return queryset.filter(slug=project.slug)


class OrganizationTeamListFilterSet(FilterSet):

"""
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 = ModelChoiceFilter(
label=_("Team"),
empty_label=_("All teams"),
field_name="teams",
to_field_name="slug",
# Queryset is required, give an empty queryset from the correct model
queryset=Team.objects.none(),
method="get_team",
)

def __init__(
self,
data=None,
queryset=None,
*,
request=None,
prefix=None,
teams_queryset=None,
):
super().__init__(data, queryset, request=request, prefix=prefix)
# Redefine the querysets used for the filter fields using the querysets
# defined at view time. This populates the filter field with only the
# correct related objects for the user/organization. Otherwise, the
# default for model choice filter fields is ``<Model>.objects.all()``.
self.filters["slug"].field.queryset = queryset.all()

def get_team(self, queryset, field_name, team):
return queryset.filter(slug=team.slug)


class OrganizationTeamMemberListFilterSet(FilterSet):

"""
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 = ModelChoiceFilter(
label=_("Team"),
empty_label=_("All teams"),
field_name="teams",
to_field_name="slug",
queryset=Team.objects.none(),
)

access = ChoiceFilter(
label=_("Access"),
empty_label=_("All access levels"),
choices=ACCESS_LEVELS + ((ACCESS_OWNER, _("Owner")),),
method="get_access",
)

def __init__(
self, data=None, queryset=None, *, request=None, prefix=None, organization=None
):
"""
Organization members filter set.

This filter set requires the following additional parameters:

:param organization: Organization for field ``filter()`` and used to
check for organization owner access.
"""
super().__init__(data, queryset, request=request, prefix=prefix)
self.organization = organization
# Redefine the querysets used for the filter fields using the querysets
# defined at view time. This populates the filter field with only the
# correct related objects for the user/organization. Otherwise, the
# default for model choice filter fields is ``<Model>.objects.all()``.
filter_with_user_relationship = True
team_queryset = self.organization.teams
if filter_with_user_relationship:
# XXX remove this conditional and decide which one of these is most
# correct There are reasons for both showing all the teams here and
# only the team that the user has access to.
team_queryset = Team.objects.member(request.user).filter(
organization=self.organization,
)
self.filters["teams__slug"].field.queryset = team_queryset.all()

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
58 changes: 52 additions & 6 deletions readthedocs/organizations/views/public.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
"""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.organizations.filters import (
OrganizationProjectListFilterSet,
OrganizationTeamListFilterSet,
OrganizationTeamMemberListFilterSet,
)
from readthedocs.organizations.models import Team
from readthedocs.organizations.views.base import (
CheckOrganizationsEnabled,
Expand Down Expand Up @@ -36,28 +42,57 @@ class DetailOrganization(OrganizationView, DetailView):
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'] = (
teams = (
Team.objects
.member(self.request.user, organization=org)
.prefetch_related('organization')
.all()
)
context['owners'] = org.owners.all()
if settings.RTD_EXT_THEME_ENABLED:
filter = OrganizationProjectListFilterSet(
self.request.GET,
request=self.request,
queryset=projects,
teams_queryset=teams,
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 guessing you are passing both querysets, so they can be re-used in the context of the old templates. I like more the idea of passing the object that the querysets need to be filtered by, like in

self.organization = organization

The filterset class could expose those querysets like OrganizationProjectListFilterSet.get_teams, so it can be re-used in the context. But not sure if that will execute the query twice (maybe cache the method if that happens).

Just an idea, not sure if this will end up being more tedious than just passing the queryset.

Copy link
Contributor Author

@agjohnson agjohnson Oct 24, 2023

Choose a reason for hiding this comment

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

I also leaned this way and would consider passing in organization instead of the extra queryset instance(s) to be a bit cleaner.

I started this direction too, but did realize that this would be confusing as all of these querysets are already defined and evaluated at the view level. There is potential to duplicate queries though, yeah.

Duplicate queries might not even be a concern though. The filters aren't used on the current dashboard and I don't think all of the view queries will be used by the views once the new dashboard is the only dashboard.

I'm solidly impartial on changing back to this at this point. But, if folks feel it's a better design to define these querysets at the FilterSet level, I'm also 👍

)
context["filter"] = filter
projects = filter.qs
context["projects"] = projects
context["teams"] = teams
context["owners"] = org.owners.all()
return context


# Member Views
class ListOrganizationMembers(OrganizationMixin, ListView):
template_name = 'organizations/member_list.html'
context_object_name = 'members'
template_name = "organizations/member_list.html"
context_object_name = "members"
admin_only = False

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
team_members = self.get_queryset()
if settings.RTD_EXT_THEME_ENABLED:
# Because of the split between TeamMember and Organization.owners,
# we have to pass in two querysets here. This is what makes it
# possible to list the available organization Teams in
filter = OrganizationTeamMemberListFilterSet(
self.request.GET,
queryset=team_members,
organization=self.get_organization(),
request=self.request,
)
context["filter"] = filter
team_members = filter.qs
context["members"] = team_members
return context

def get_queryset(self):
return self.get_organization().members

Expand All @@ -78,6 +113,18 @@ def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
org = self.get_organization()
context['owners'] = org.owners.all()

# Note, the team queryset defines sorting at the parent class. Sorting
# should happen in the filter instead so it can be controlled in the UI.
teams = self.get_team_queryset()
if settings.RTD_EXT_THEME_ENABLED:
filter = OrganizationTeamListFilterSet(
self.request.GET,
queryset=teams,
)
context["filter"] = filter
teams = filter.qs
context["teams"] = teams
return context


Expand All @@ -96,7 +143,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"]])
Expand Down