Skip to content

Commit be98bc0

Browse files
mfraezzaaxelbJohnetordoff
authored
[Feature Release][ENG-5024] Institutional Dashboard Improvements (#10797)
Add support for new Institutional Dashboard gated by a waffle flag --------- Co-authored-by: abram axel booth <[email protected]> Co-authored-by: John Tordoff <[email protected]> Co-authored-by: John Tordoff <>
1 parent 526ba32 commit be98bc0

File tree

77 files changed

+3914
-363
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+3914
-363
lines changed

admin/management/views.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import datetime
21
from dateutil.parser import isoparse
32
from django.views.generic import TemplateView, View
43
from django.contrib import messages
@@ -120,11 +119,11 @@ def post(self, request, *args, **kwargs):
120119
if monthly_report_date:
121120
report_date = isoparse(monthly_report_date).date()
122121
else:
123-
report_date = datetime.datetime.now().date()
122+
report_date = None
124123

125124
errors = monthly_reporters_go(
126-
report_month=report_date.month,
127-
report_year=report_date.year
125+
report_month=getattr(report_date, 'month', None),
126+
report_year=getattr(report_date, 'year', None)
128127
)
129128

130129
if errors:

api/base/elasticsearch_dsl_views.py

+172
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
from __future__ import annotations
2+
import abc
3+
import datetime
4+
import typing
5+
6+
import elasticsearch_dsl as edsl
7+
from rest_framework import generics, exceptions as drf_exceptions
8+
from rest_framework.settings import api_settings as drf_settings
9+
from api.base.settings.defaults import REPORT_FILENAME_FORMAT
10+
11+
if typing.TYPE_CHECKING:
12+
from rest_framework import serializers
13+
14+
from api.base.filters import FilterMixin
15+
from api.base.views import JSONAPIBaseView
16+
from api.metrics.renderers import (
17+
MetricsReportsCsvRenderer,
18+
MetricsReportsTsvRenderer,
19+
MetricsReportsJsonRenderer,
20+
)
21+
from api.base.pagination import ElasticsearchQuerySizeMaximumPagination, JSONAPIPagination
22+
from api.base.renderers import JSONAPIRenderer
23+
24+
25+
class ElasticsearchListView(FilterMixin, JSONAPIBaseView, generics.ListAPIView, abc.ABC):
26+
'''abstract view class using `elasticsearch_dsl.Search` as a queryset-analogue
27+
28+
builds a `Search` based on `self.get_default_search()` and the request's
29+
query parameters for filtering, sorting, and pagination -- fetches only
30+
the data required for the response, just like with a queryset!
31+
'''
32+
serializer_class: type[serializers.BaseSerializer] # required on subclasses
33+
34+
default_ordering: str | None = None # name of a serializer field, prepended with "-" for descending sort
35+
ordering_fields: frozenset[str] = frozenset() # serializer field names
36+
37+
@abc.abstractmethod
38+
def get_default_search(self) -> edsl.Search | None:
39+
'''the base `elasticsearch_dsl.Search` for this list, based on url path
40+
41+
(common jsonapi query parameters will be considered automatically)
42+
'''
43+
...
44+
45+
FILE_RENDERER_CLASSES = {
46+
MetricsReportsCsvRenderer,
47+
MetricsReportsTsvRenderer,
48+
MetricsReportsJsonRenderer,
49+
}
50+
51+
def set_content_disposition(self, response, renderer: str):
52+
"""Set the Content-Disposition header to prompt a file download with the appropriate filename.
53+
54+
Args:
55+
response: The HTTP response object to modify.
56+
renderer: The renderer instance used for the response, which determines the file extension.
57+
"""
58+
current_date = datetime.datetime.now().strftime('%Y-%m')
59+
60+
if isinstance(renderer, JSONAPIRenderer):
61+
extension = 'json'
62+
else:
63+
extension = getattr(renderer, 'extension', renderer.format)
64+
65+
filename = REPORT_FILENAME_FORMAT.format(
66+
view_name=self.view_name,
67+
date_created=current_date,
68+
extension=extension,
69+
)
70+
71+
response['Content-Disposition'] = f'attachment; filename="{filename}"'
72+
73+
def finalize_response(self, request, response, *args, **kwargs):
74+
# Call the parent method to finalize the response first
75+
response = super().finalize_response(request, response, *args, **kwargs)
76+
# Check if this is a direct download request or file renderer classes, set to the Content-Disposition header
77+
# so filename and attachment for browser download
78+
if isinstance(request.accepted_renderer, tuple(self.FILE_RENDERER_CLASSES)):
79+
self.set_content_disposition(response, request.accepted_renderer)
80+
81+
return response
82+
83+
###
84+
# beware! inheritance shenanigans below
85+
86+
# override FilterMixin to disable all operators besides 'eq' and 'ne'
87+
MATCHABLE_FIELDS = ()
88+
COMPARABLE_FIELDS = ()
89+
DEFAULT_OPERATOR_OVERRIDES = {}
90+
# (if you want to add fulltext-search or range-filter support, remove the override
91+
# and update `__add_search_filter` to handle those operators -- tho note that the
92+
# underlying elasticsearch field mapping will need to be compatible with the query)
93+
94+
# override DEFAULT_FILTER_BACKENDS rest_framework setting
95+
# (filtering handled in-view to reuse logic from FilterMixin)
96+
filter_backends = ()
97+
98+
# note: because elasticsearch_dsl.Search supports slicing and gives results when iterated on,
99+
# it works fine with default pagination
100+
101+
# override rest_framework.generics.GenericAPIView
102+
@property
103+
def pagination_class(self):
104+
"""
105+
When downloading a file assume no pagination is necessary unless the user specifies
106+
"""
107+
is_file_download = any(
108+
self.request.accepted_renderer.format == renderer.format
109+
for renderer in self.FILE_RENDERER_CLASSES
110+
)
111+
# if it's a file download of the JSON respect default page size
112+
if is_file_download:
113+
return ElasticsearchQuerySizeMaximumPagination
114+
return JSONAPIPagination
115+
116+
def get_queryset(self):
117+
_search = self.get_default_search()
118+
if _search is None:
119+
return []
120+
# using parsing logic from FilterMixin (oddly nested dict and all)
121+
for _parsed_param in self.parse_query_params(self.request.query_params).values():
122+
for _parsed_filter in _parsed_param.values():
123+
_search = self.__add_search_filter(
124+
_search,
125+
elastic_field_name=_parsed_filter['source_field_name'],
126+
operator=_parsed_filter['op'],
127+
value=_parsed_filter['value'],
128+
)
129+
return self.__add_sort(_search)
130+
131+
###
132+
# private methods
133+
134+
def __add_sort(self, search: edsl.Search) -> edsl.Search:
135+
_elastic_sort = self.__get_elastic_sort()
136+
return (search if _elastic_sort is None else search.sort(_elastic_sort))
137+
138+
def __get_elastic_sort(self) -> str | None:
139+
_sort_param = self.request.query_params.get(drf_settings.ORDERING_PARAM, self.default_ordering)
140+
if not _sort_param:
141+
return None
142+
_sort_field, _ascending = (
143+
(_sort_param[1:], False)
144+
if _sort_param.startswith('-')
145+
else (_sort_param, True)
146+
)
147+
if _sort_field not in self.ordering_fields:
148+
raise drf_exceptions.ValidationError(
149+
f'invalid value for {drf_settings.ORDERING_PARAM} query param (valid values: {", ".join(self.ordering_fields)})',
150+
)
151+
_serializer_field = self.get_serializer().fields[_sort_field]
152+
_elastic_sort_field = _serializer_field.source
153+
return (_elastic_sort_field if _ascending else f'-{_elastic_sort_field}')
154+
155+
def __add_search_filter(
156+
self,
157+
search: edsl.Search,
158+
elastic_field_name: str,
159+
operator: str,
160+
value: str,
161+
) -> edsl.Search:
162+
match operator: # operators from FilterMixin
163+
case 'eq':
164+
if value == '':
165+
return search.exclude('exists', field=elastic_field_name)
166+
return search.filter('term', **{elastic_field_name: value})
167+
case 'ne':
168+
if value == '':
169+
return search.filter('exists', field=elastic_field_name)
170+
return search.exclude('term', **{elastic_field_name: value})
171+
case _:
172+
raise NotImplementedError(f'unsupported filter operator "{operator}"')

api/base/pagination.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
replace_query_param, remove_query_param,
1111
)
1212
from api.base.serializers import is_anonymized
13-
from api.base.settings import MAX_PAGE_SIZE
13+
from api.base.settings import MAX_PAGE_SIZE, MAX_SIZE_OF_ES_QUERY
1414
from api.base.utils import absolute_reverse
1515

1616
from osf.models import AbstractNode, Comment, Preprint, Guid, DraftRegistration
@@ -172,6 +172,13 @@ class MaxSizePagination(JSONAPIPagination):
172172
max_page_size = None
173173
page_size_query_param = None
174174

175+
176+
class ElasticsearchQuerySizeMaximumPagination(JSONAPIPagination):
177+
page_size = MAX_SIZE_OF_ES_QUERY
178+
max_page_size = MAX_SIZE_OF_ES_QUERY
179+
page_size_query_param = None
180+
181+
175182
class NoMaxPageSizePagination(JSONAPIPagination):
176183
max_page_size = None
177184

api/base/serializers.py

+28
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from api.base import utils
1919
from api.base.exceptions import EnumFieldMemberError
20+
from osf.metrics.utils import YearMonth
2021
from osf.utils import permissions as osf_permissions
2122
from osf.utils import sanitize
2223
from osf.utils import functional
@@ -171,6 +172,18 @@ def should_show(self, instance):
171172
return request and (request.user.is_anonymous or has_admin_scope)
172173

173174

175+
class ShowIfObjectPermission(ConditionalField):
176+
"""Show the field only for users with a given object permission
177+
"""
178+
def __init__(self, field, *, permission: str, **kwargs):
179+
super().__init__(field, **kwargs)
180+
self._required_object_permission = permission
181+
182+
def should_show(self, instance):
183+
_request = self.context.get('request')
184+
return _request.user.has_perm(self._required_object_permission, obj=instance)
185+
186+
174187
class HideIfRegistration(ConditionalField):
175188
"""
176189
If node is a registration, this field will return None.
@@ -2012,3 +2025,18 @@ def to_internal_value(self, data):
20122025
return self._enum_class[data.upper()].value
20132026
except KeyError:
20142027
raise EnumFieldMemberError(self._enum_class, data)
2028+
2029+
2030+
class YearmonthField(ser.Field):
2031+
def to_representation(self, value: YearMonth | None) -> str | None:
2032+
if value is None:
2033+
return None
2034+
return str(value)
2035+
2036+
def to_internal_value(self, data: str | None) -> YearMonth | None:
2037+
if data is None:
2038+
return None
2039+
try:
2040+
return YearMonth.from_str(data)
2041+
except ValueError as e:
2042+
raise ser.ValidationError(str(e))

api/base/settings/defaults.py

+3
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,11 @@
359359

360360
MAX_SIZE_OF_ES_QUERY = 10000
361361
DEFAULT_ES_NULL_VALUE = 'N/A'
362+
REPORT_FILENAME_FORMAT = '{view_name}_{date_created}.{extension}'
362363

363364
CI_ENV = False
364365

365366
CITATION_STYLES_REPO_URL = 'https://github.com/CenterForOpenScience/styles/archive/88e6ed31a91e9f5a480b486029cda97b535935d4.zip'
366367
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
368+
369+
WAFFLE_ENABLE_ADMIN_PAGES = False # instead, customized waffle admins in osf/admin.py

api/base/utils.py

+19
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from urllib.parse import urlunsplit, urlsplit, parse_qs, urlencode
33
from packaging.version import Version
44
from hashids import Hashids
5+
import waffle
56

67
from django.apps import apps
78
from django.core.exceptions import ObjectDoesNotExist
@@ -275,3 +276,21 @@ def __len__(self):
275276
def add_dict_as_item(self, dict):
276277
item = type('item', (object,), dict)
277278
self.append(item)
279+
280+
281+
def toggle_view_by_flag(flag_name, old_view, new_view):
282+
'''toggle between view implementations based on a feature flag
283+
284+
returns a wrapper view function that:
285+
- when the given flag is inactive, passes thru to `old_view`
286+
- when the given flag is active, passes thru to `new_view`
287+
'''
288+
def _view_by_flag(request, *args, **kwargs):
289+
if waffle.flag_is_active(request, flag_name):
290+
return new_view(request, *args, **kwargs)
291+
return old_view(request, *args, **kwargs)
292+
if hasattr(new_view, 'view_class'):
293+
# set view_class to masquerade as a class-based view, for sake of assumptions
294+
# in `api_tests.base.test_views` and `api.base.serializers.RelationshipField`
295+
_view_by_flag.view_class = new_view.view_class # type: ignore[attr-defined]
296+
return _view_by_flag

0 commit comments

Comments
 (0)