Skip to content

Commit 0e89c2e

Browse files
authored
Merge pull request #8142 from readthedocs/agj/add-project-build-filters
Add project/build filters
2 parents b523a78 + 20cf911 commit 0e89c2e

File tree

5 files changed

+247
-13
lines changed

5 files changed

+247
-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

@@ -135,7 +133,13 @@ def get_context_data(self, **kwargs): # pylint: disable=arguments-differ
135133
context['project'] = self.project
136134
context['active_builds'] = active_builds
137135
context['versions'] = self._get_versions(self.project)
138-
context['build_qs'] = self.get_queryset()
136+
137+
builds = self.get_queryset()
138+
if settings.RTD_EXT_THEME_ENABLED:
139+
filter = BuildListFilter(self.request.GET, queryset=builds)
140+
context['filter'] = filter
141+
builds = filter.qs
142+
context['build_qs'] = builds
139143

140144
return context
141145

readthedocs/projects/filters.py

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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 VersionSortOrderingFilter(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+
The empty/None value is used to provide both a default value to the filter
22+
(when there is no ``sort`` query param), but also provide an option that is
23+
manually selectable (``?sort=relevance``). We can't do this with the default
24+
filter, because result would be params like ``?sort=None``.
25+
"""
26+
27+
def __init__(self, *args, **kwargs):
28+
super().__init__(*args, **kwargs)
29+
self.extra['choices'] = [
30+
('name', _('Name')),
31+
('-name', _('Name (descending)')),
32+
('-recent', _('Most recently built')),
33+
('recent', _('Least recently built')),
34+
]
35+
36+
def filter(self, qs, value):
37+
# This is where we use the None value for this custom filter. This
38+
# doesn't work with a standard model filter. Note: ``value`` is always
39+
# an iterable, but can be empty.
40+
if not value:
41+
value = ['relevance']
42+
43+
# Selectively add anotations as a small query optimization
44+
annotations = {
45+
# Default ordering is number of builds, but could be another proxy
46+
# for version populatrity
47+
'relevance': {'relevance': Count('builds')},
48+
# Most recent build date, this appears inverted in the option value
49+
'recent': {'recent': Max('builds__date')},
50+
# Alias field name here, as ``OrderingFilter`` was having trouble
51+
# doing this with it's native field mapping
52+
'name': {'name': F('verbose_name')},
53+
}
54+
# And copy the negative sort lookups, ``value`` might be ``['-recent']``
55+
annotations.update({f'-{key}': value for (key, value) in annotations.items()})
56+
57+
annotation = annotations.get(*value)
58+
return qs.annotate(**annotation).order_by(*value)
59+
60+
61+
class ProjectSortOrderingFilter(OrderingFilter):
62+
63+
"""
64+
Project list sort ordering django_filters filter.
65+
66+
Django-filter is highly opionated, and the default model filters do not work
67+
well with empty/null values in the filter choices. In our case, empty/null
68+
values are used for a default query. So, to make this work, we will use a
69+
custom filter, instead of an automated model filter.
70+
"""
71+
72+
SORT_NAME = 'name'
73+
SORT_MODIFIED_DATE = 'modified_date'
74+
SORT_BUILD_DATE = 'built_last'
75+
SORT_BUILD_COUNT = 'build_count'
76+
77+
def __init__(self, *args, **kwargs):
78+
super().__init__(*args, **kwargs)
79+
self.extra['choices'] = [
80+
(f'-{self.SORT_NAME}', _('Name (descending)')),
81+
(f'-{self.SORT_MODIFIED_DATE}', _('Most recently modified')),
82+
(self.SORT_MODIFIED_DATE, _('Least recently modified')),
83+
(self.SORT_BUILD_DATE, _('Most recently built')),
84+
(f'-{self.SORT_BUILD_DATE}', _('Least recently built')),
85+
(f'-{self.SORT_BUILD_COUNT}', _('Most frequently built')),
86+
(self.SORT_BUILD_COUNT, _('Least frequently built')),
87+
]
88+
89+
def filter(self, qs, value):
90+
# This is where we use the None value from the custom filter
91+
if value is None:
92+
value = [self.SORT_NAME]
93+
return qs.annotate(
94+
**{
95+
self.SORT_BUILD_DATE: Max('versions__builds__date'),
96+
self.SORT_BUILD_COUNT: Count('versions__builds'),
97+
}
98+
).order_by(*value)
99+
100+
101+
class ProjectListFilterSet(FilterSet):
102+
103+
"""
104+
Project list filter set for project list view.
105+
106+
This filter set enables list view sorting using a custom filter, and
107+
provides search-as-you-type lookup filter as well.
108+
"""
109+
110+
project = CharFilter(field_name='slug', widget=HiddenInput)
111+
sort = ProjectSortOrderingFilter(
112+
field_name='sort',
113+
label=_('Sort by'),
114+
empty_label=_('Name'),
115+
)
116+
117+
118+
class ProjectVersionListFilterSet(FilterSet):
119+
120+
"""
121+
Filter and sorting for project version listing page.
122+
123+
This is used from the project versions list view page to provide filtering
124+
and sorting to the version list and search UI. It is normally instantiated
125+
with an included queryset, which provides user project authorization.
126+
"""
127+
128+
VISIBILITY_HIDDEN = 'hidden'
129+
VISIBILITY_VISIBLE = 'visible'
130+
131+
VISIBILITY_CHOICES = (
132+
('hidden', _('Hidden versions')),
133+
('visible', _('Visible versions')),
134+
)
135+
136+
PRIVACY_CHOICES = (
137+
('public', _('Public versions')),
138+
('private', _('Private versions')),
139+
)
140+
141+
# Attribute filter fields
142+
version = CharFilter(field_name='slug', widget=HiddenInput)
143+
privacy = ChoiceFilter(
144+
field_name='privacy_level',
145+
label=_('Privacy'),
146+
choices=PRIVACY_CHOICES,
147+
empty_label=_('Any'),
148+
)
149+
# This field looks better as ``visibility=hidden`` than it does
150+
# ``hidden=true``, otherwise we could use a BooleanFilter instance here
151+
# instead
152+
visibility = ChoiceFilter(
153+
field_name='hidden',
154+
label=_('Visibility'),
155+
choices=VISIBILITY_CHOICES,
156+
method='get_visibility',
157+
empty_label=_('Any'),
158+
)
159+
160+
sort = VersionSortOrderingFilter(
161+
field_name='sort',
162+
label=_('Sort by'),
163+
empty_label=_('Relevance'),
164+
)
165+
166+
def get_visibility(self, queryset, name, value):
167+
if value == self.VISIBILITY_HIDDEN:
168+
return queryset.filter(hidden=True)
169+
if value == self.VISIBILITY_VISIBLE:
170+
return queryset.filter(hidden=False)
171+
return queryset

readthedocs/projects/views/private.py

+9
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,14 @@ 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+
if settings.RTD_EXT_THEME_ENABLED:
106+
filter = ProjectListFilterSet(self.request.GET, queryset=self.get_queryset())
107+
context['filter'] = filter
108+
context['project_list'] = filter.qs
109+
# Alternatively, dynamically override super()-derived `project_list` context_data
110+
# context[self.get_context_object_name(filter.qs)] = filter.qs
111+
103112
return context
104113

105114
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)