Skip to content

Commit 57d6fbb

Browse files
committed
Add project/build filters
Most of these filters are currently only used in views when the new templates are enabled. The project dashboard view context data was replaced with a filter, which does enable sorting on the dashboard view using request arguments (like `?sort=built_last`), but this isn't exposed in the UI.
1 parent 43786c8 commit 57d6fbb

File tree

5 files changed

+233
-13
lines changed

5 files changed

+233
-13
lines changed

readthedocs/builds/filters.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import logging
2+
3+
from django.forms.widgets import HiddenInput
4+
from django.utils.translation import ugettext_lazy as _
5+
from django_filters import CharFilter, ChoiceFilter, FilterSet
6+
7+
from readthedocs.builds.constants import BUILD_STATE_FINISHED
8+
9+
log = logging.getLogger(__name__)
10+
11+
12+
class BuildListFilter(FilterSet):
13+
14+
STATE_ACTIVE = 'active'
15+
STATE_SUCCESS = 'succeeded'
16+
STATE_FAILED = 'failed'
17+
18+
STATE_CHOICES = (
19+
(STATE_ACTIVE, _('Active')),
20+
(STATE_SUCCESS, _('Build finished')),
21+
(STATE_FAILED, _('Build failed')),
22+
)
23+
24+
# Attribute filter fields
25+
version = CharFilter(field_name='version__slug', widget=HiddenInput)
26+
state = ChoiceFilter(
27+
label=_('State'),
28+
choices=STATE_CHOICES,
29+
empty_label=_('Any'),
30+
method='get_state',
31+
)
32+
33+
def get_state(self, queryset, name, value):
34+
if value == self.STATE_ACTIVE:
35+
queryset = queryset.exclude(state=BUILD_STATE_FINISHED)
36+
elif value == self.STATE_SUCCESS:
37+
queryset = queryset.filter(state=BUILD_STATE_FINISHED, success=True)
38+
elif value == self.STATE_FAILED:
39+
queryset = queryset.filter(
40+
state=BUILD_STATE_FINISHED,
41+
success=False,
42+
)
43+
return queryset

readthedocs/builds/views.py

+10-6
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,23 @@
44
import textwrap
55
from urllib.parse import urlparse
66

7+
from django.conf import settings
78
from django.contrib import messages
89
from django.contrib.auth.decorators import login_required
9-
from django.http import (
10-
HttpResponseForbidden,
11-
HttpResponseRedirect,
12-
)
10+
from django.http import HttpResponseForbidden, HttpResponseRedirect
1311
from django.shortcuts import get_object_or_404
1412
from django.urls import reverse
1513
from django.utils.decorators import method_decorator
1614
from django.views.generic import DetailView, ListView
1715
from requests.utils import quote
1816

17+
from readthedocs.builds.filters import BuildListFilter
1918
from readthedocs.builds.models import Build, Version
2019
from readthedocs.core.permissions import AdminPermission
2120
from readthedocs.core.utils import trigger_build
2221
from readthedocs.doc_builder.exceptions import BuildEnvironmentError
2322
from readthedocs.projects.models import Project
2423

25-
2624
log = logging.getLogger(__name__)
2725

2826

@@ -97,7 +95,13 @@ def get_context_data(self, **kwargs): # pylint: disable=arguments-differ
9795
context['project'] = self.project
9896
context['active_builds'] = active_builds
9997
context['versions'] = self._get_versions(self.project)
100-
context['build_qs'] = self.get_queryset()
98+
99+
builds = self.get_queryset()
100+
if settings.RTD_EXT_THEME_ENABLED:
101+
filter = BuildListFilter(self.request.GET, queryset=builds)
102+
context['filter'] = filter
103+
builds = filter.qs
104+
context['build_qs'] = builds
101105

102106
return context
103107

readthedocs/projects/filters.py

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import logging
2+
3+
from django.db.models import Count, F, Max
4+
from django.forms.widgets import HiddenInput
5+
from django.utils.translation import ugettext_lazy as _
6+
from django_filters import CharFilter, ChoiceFilter, FilterSet, OrderingFilter
7+
8+
log = logging.getLogger(__name__)
9+
10+
11+
class SortOrderingFilter(OrderingFilter):
12+
13+
"""
14+
Version list sort ordering django_filters filter.
15+
16+
Django-filter is highly opionated, and the default model filters do not work
17+
well with empty/null values in the filter choices. In our case, empty/null
18+
values are used for a default query. So, to make this work, we will use a
19+
custom filter, instead of an automated model filter.
20+
"""
21+
22+
def __init__(self, *args, **kwargs):
23+
super().__init__(*args, **kwargs)
24+
self.extra['choices'] = [
25+
('name', _('Name')),
26+
('-name', _('Name (descending)')),
27+
('-recent', _('Most recently built')),
28+
('recent', _('Least recently built')),
29+
]
30+
31+
def filter(self, qs, value):
32+
# This is where we use the None value for this custom filter. This
33+
# doesn't work with a standard model filter.
34+
if value is None:
35+
value = ['relevance']
36+
return qs.annotate(
37+
# Default ordering is number of builds, but could be another proxy
38+
# for version populatrity
39+
relevance=Count('builds'),
40+
# Most recent build date, this appears inverted in the option value
41+
recent=Max('builds__date'),
42+
# Alias field name here, as ``OrderingFilter`` was having trouble
43+
# doing this with it's native field mapping
44+
name=F('verbose_name'),
45+
).order_by(*value)
46+
47+
48+
class ProjectSortOrderingFilter(OrderingFilter):
49+
50+
"""
51+
Project list sort ordering django_filters filter.
52+
53+
Django-filter is highly opionated, and the default model filters do not work
54+
well with empty/null values in the filter choices. In our case, empty/null
55+
values are used for a default query. So, to make this work, we will use a
56+
custom filter, instead of an automated model filter.
57+
"""
58+
59+
SORT_NAME = 'name'
60+
SORT_MODIFIED_DATE = 'modified_date'
61+
SORT_BUILD_DATE = 'built_last'
62+
SORT_BUILD_COUNT = 'build_count'
63+
64+
def __init__(self, *args, **kwargs):
65+
super().__init__(*args, **kwargs)
66+
self.extra['choices'] = [
67+
(f'-{self.SORT_NAME}', _('Name (descending)')),
68+
(f'-{self.SORT_MODIFIED_DATE}', _('Most recently modified')),
69+
(self.SORT_MODIFIED_DATE, _('Least recently modified')),
70+
(self.SORT_BUILD_DATE, _('Most recently built')),
71+
(f'-{self.SORT_BUILD_DATE}', _('Least recently built')),
72+
(f'-{self.SORT_BUILD_COUNT}', _('Most frequently built')),
73+
(self.SORT_BUILD_COUNT, _('Least frequently built')),
74+
]
75+
76+
def filter(self, qs, value):
77+
# This is where we use the None value from the custom filter
78+
if value is None:
79+
value = [self.SORT_NAME]
80+
return qs.annotate(
81+
**{
82+
self.SORT_BUILD_DATE: Max('versions__builds__date'),
83+
self.SORT_BUILD_COUNT: Count('versions__builds'),
84+
}
85+
).order_by(*value)
86+
87+
88+
class ProjectListFilterSet(FilterSet):
89+
90+
"""
91+
Project list filter set for project list view.
92+
93+
This filter set enables list view sorting using a custom filter, and
94+
provides search-as-you-type lookup filter as well.
95+
"""
96+
97+
project = CharFilter(field_name='slug', widget=HiddenInput)
98+
sort = ProjectSortOrderingFilter(
99+
field_name='sort',
100+
label=_('Sort by'),
101+
empty_label=_('Name'),
102+
)
103+
104+
105+
class ProjectVersionListFilterSet(FilterSet):
106+
107+
"""
108+
Filter and sorting for project version listing page.
109+
110+
This is used from the project versions list view page to provide filtering
111+
and sorting to the version list and search UI. It is normally instantiated
112+
with an included queryset, which provides user project authorization.
113+
"""
114+
115+
VISIBILITY_HIDDEN = 'hidden'
116+
VISIBILITY_VISIBLE = 'visible'
117+
118+
VISIBILITY_CHOICES = (
119+
('hidden', _('Hidden versions')),
120+
('visible', _('Visible versions')),
121+
)
122+
123+
PRIVACY_CHOICES = (
124+
('public', _('Public versions')),
125+
('private', _('Private versions')),
126+
)
127+
128+
# Attribute filter fields
129+
version = CharFilter(field_name='slug', widget=HiddenInput)
130+
privacy = ChoiceFilter(
131+
field_name='privacy_level',
132+
label=_('Privacy'),
133+
choices=PRIVACY_CHOICES,
134+
empty_label=_('Any'),
135+
)
136+
# This field looks better as ``visibility=hidden`` than it does
137+
# ``hidden=true``, otherwise we could use a BooleanFilter instance here
138+
# instead
139+
visibility = ChoiceFilter(
140+
field_name='hidden',
141+
label=_('Visibility'),
142+
choices=VISIBILITY_CHOICES,
143+
method='get_visibility',
144+
empty_label=_('Any'),
145+
)
146+
147+
sort = ProjectSortOrderingFilter(
148+
field_name='sort',
149+
label=_('Sort by'),
150+
empty_label=_('Relevance'),
151+
)
152+
153+
def get_visibility(self, queryset, name, value):
154+
if value == self.VISIBILITY_HIDDEN:
155+
return queryset.filter(hidden=True)
156+
if value == self.VISIBILITY_VISIBLE:
157+
return queryset.filter(hidden=False)
158+
return queryset

readthedocs/projects/views/private.py

+8
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
from readthedocs.oauth.tasks import attach_webhook
5252
from readthedocs.oauth.utils import update_webhook
5353
from readthedocs.projects import tasks
54+
from readthedocs.projects.filters import ProjectListFilterSet
5455
from readthedocs.projects.forms import (
5556
DomainForm,
5657
EmailHookForm,
@@ -100,6 +101,13 @@ def get_context_data(self, **kwargs):
100101
context = super().get_context_data(**kwargs)
101102
# Set the default search to search files instead of projects
102103
context['type'] = 'file'
104+
105+
filter = ProjectListFilterSet(self.request.GET, queryset=self.get_queryset())
106+
context['filter'] = filter
107+
context['project_list'] = filter.qs
108+
# Alternatively, dynamically override super()-derived `project_list` context_data
109+
# context[self.get_context_object_name(filter.qs)] = filter.qs
110+
103111
return context
104112

105113
def validate_primary_email(self, user):

readthedocs/projects/views/public.py

+14-7
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from readthedocs.builds.views import BuildTriggerMixin
3232
from readthedocs.core.permissions import AdminPermission
3333
from readthedocs.core.utils.extend import SettingsOverrideObject
34+
from readthedocs.projects.filters import ProjectVersionListFilterSet
3435
from readthedocs.projects.models import Project
3536
from readthedocs.projects.templatetags.projects_tags import sort_version_aware
3637
from readthedocs.projects.views.mixins import ProjectRelationListMixin
@@ -86,12 +87,8 @@ def project_redirect(request, invalid_project_slug):
8687
))
8788

8889

89-
class ProjectDetailViewBase(
90-
ProjectRelationListMixin,
91-
BuildTriggerMixin,
92-
ProjectOnboardMixin,
93-
DetailView
94-
):
90+
class ProjectDetailViewBase(ProjectRelationListMixin, BuildTriggerMixin,
91+
ProjectOnboardMixin, DetailView):
9592

9693
"""Display project onboard steps."""
9794

@@ -108,7 +105,17 @@ def get_context_data(self, **kwargs):
108105
context = super().get_context_data(**kwargs)
109106

110107
project = self.get_project()
111-
context['versions'] = self._get_versions(project)
108+
109+
# Get filtered and sorted versions
110+
versions = self._get_versions(project)
111+
if settings.RTD_EXT_THEME_ENABLED:
112+
filter = ProjectVersionListFilterSet(
113+
self.request.GET,
114+
queryset=versions,
115+
)
116+
context['filter'] = filter
117+
versions = filter.qs
118+
context['versions'] = versions
112119

113120
protocol = 'http'
114121
if self.request.is_secure():

0 commit comments

Comments
 (0)