diff --git a/api/institutions/serializers.py b/api/institutions/serializers.py index 70afb93c157..ed7e34af91a 100644 --- a/api/institutions/serializers.py +++ b/api/institutions/serializers.py @@ -359,6 +359,53 @@ def get_absolute_url(self): return None # there is no detail view for institution-users +class NewInstitutionSummaryMetricsSerializer(JSONAPISerializer): + '''serializer for institution-summary metrics + + used only when the INSTITUTIONAL_DASHBOARD_2024 feature flag is active + (and should be renamed without "New" when that flag is permanently active) + + Summary contains counts of + - Total users in the institution + - Total public project count for the institution + - Total private project count for the institution + - Total public registration count for the institution + - Total private registration count for the institution + - Total published preprint count for the institution + + ''' + + class Meta: + type_ = 'institution-summary-metrics' + + id = IDField(read_only=True) + + user_count = ser.IntegerField(read_only=True) + public_project_count = ser.IntegerField(read_only=True) + private_project_count = ser.IntegerField(read_only=True) + public_registration_count = ser.IntegerField(read_only=True) + embargoed_registration_count = ser.IntegerField(read_only=True) + published_preprint_count = ser.IntegerField(read_only=True) + public_file_count = ser.IntegerField(read_only=True) + storage_byte_count = ser.IntegerField(read_only=True) + monthly_logged_in_user_count = ser.IntegerField(read_only=True) + monthly_active_user_count = ser.IntegerField(read_only=True) + + user = RelationshipField( + related_view='users:user-detail', + related_view_kwargs={'user_id': ''}, + ) + institution = RelationshipField( + related_view='institutions:institution-detail', + related_view_kwargs={'institution_id': ''}, + ) + + links = LinksField({}) + + def get_absolute_url(self): + return None # there is no detail view for institution-users + + class InstitutionRelated(JSONAPIRelationshipSerializer): id = ser.CharField(source='_id', required=False, allow_null=True) class Meta: diff --git a/api/institutions/urls.py b/api/institutions/urls.py index eb13d5728f0..9daffa65f73 100644 --- a/api/institutions/urls.py +++ b/api/institutions/urls.py @@ -13,7 +13,7 @@ re_path(r'^(?P\w+)/relationships/registrations/$', views.InstitutionRegistrationsRelationship.as_view(), name=views.InstitutionRegistrationsRelationship.view_name), re_path(r'^(?P\w+)/relationships/nodes/$', views.InstitutionNodesRelationship.as_view(), name=views.InstitutionNodesRelationship.view_name), re_path(r'^(?P\w+)/users/$', views.InstitutionUserList.as_view(), name=views.InstitutionUserList.view_name), - re_path(r'^(?P\w+)/metrics/summary/$', views.InstitutionSummaryMetrics.as_view(), name=views.InstitutionSummaryMetrics.view_name), + re_path(r'^(?P\w+)/metrics/summary/$', views.institution_summary_metrics_list_view, name=views.institution_summary_metrics_list_view.view_name), re_path(r'^(?P\w+)/metrics/departments/$', views.InstitutionDepartmentList.as_view(), name=views.InstitutionDepartmentList.view_name), re_path(r'^(?P\w+)/metrics/users/$', views.institution_user_metrics_list_view, name=views.institution_user_metrics_list_view.view_name), ] diff --git a/api/institutions/views.py b/api/institutions/views.py index c21dd6bddb7..e5802e73fc8 100644 --- a/api/institutions/views.py +++ b/api/institutions/views.py @@ -12,7 +12,7 @@ from osf.metrics import InstitutionProjectCounts from osf.models import OSFUser, Node, Institution, Registration from osf.metrics import UserInstitutionProjectCounts -from osf.metrics.reports import InstitutionalUserReport +from osf.metrics.reports import InstitutionalUserReport, InstitutionMonthlySummaryReport from osf.utils import permissions as osf_permissions from api.base import permissions as base_permissions @@ -48,6 +48,7 @@ InstitutionDepartmentMetricsSerializer, NewInstitutionUserMetricsSerializer, OldInstitutionUserMetricsSerializer, + NewInstitutionSummaryMetricsSerializer, ) from api.institutions.permissions import UserIsAffiliated from api.institutions.renderers import InstitutionDepartmentMetricsCSVRenderer, InstitutionUserMetricsCSVRenderer, MetricsCSVRenderer @@ -391,7 +392,7 @@ def create(self, *args, **kwargs): return ret -class InstitutionSummaryMetrics(JSONAPIBaseView, generics.RetrieveAPIView, InstitutionMixin): +class _OldInstitutionSummaryMetrics(JSONAPIBaseView, generics.RetrieveAPIView, InstitutionMixin): permission_classes = ( drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, @@ -581,6 +582,60 @@ def get_default_search(self): ) +class _NewInstitutionSummaryMetricsList(JSONAPIBaseView, generics.RetrieveAPIView, InstitutionMixin): + '''list view for institution-summary metrics + + used only when the INSTITUTIONAL_DASHBOARD_2024 feature flag is active + (and should be renamed without "New" when that flag is permanently active) + ''' + permission_classes = ( + drf_permissions.IsAuthenticatedOrReadOnly, + base_permissions.TokenHasScope, + IsInstitutionalMetricsUser, + ) + + required_read_scopes = [CoreScopes.INSTITUTION_METRICS_READ] + required_write_scopes = [CoreScopes.NULL] + + view_category = 'institutions' + view_name = 'institution-summary-metrics' + + serializer_class = NewInstitutionSummaryMetricsSerializer + + def get_object(self): + institution = self.get_institution() + search_object = self.get_default_search() + if search_object: + object = search_object.execute()[0] + object.id = institution._id + return object + + def get_default_search(self): + _yearmonth = InstitutionMonthlySummaryReport.most_recent_yearmonth() + if _yearmonth is None: + return None + return ( + InstitutionMonthlySummaryReport.search() + .filter('term', report_yearmonth=str(_yearmonth)) + .filter('term', institution_id=self.get_institution()._id) + ) + + +institution_user_metrics_list_view = toggle_view_by_flag( + flag_name=osf.features.INSTITUTIONAL_DASHBOARD_2024, + old_view=_OldInstitutionUserMetricsList.as_view(), + new_view=_NewInstitutionUserMetricsList.as_view(), +) +institution_user_metrics_list_view.view_name = 'institution-user-metrics' + +institution_summary_metrics_list_view = toggle_view_by_flag( + flag_name=osf.features.INSTITUTIONAL_DASHBOARD_2024, + old_view=_OldInstitutionSummaryMetrics.as_view(), + new_view=_NewInstitutionSummaryMetricsList.as_view(), +) +institution_summary_metrics_list_view.view_name = 'institution-summary-metrics' + + institution_user_metrics_list_view = toggle_view_by_flag( flag_name=osf.features.INSTITUTIONAL_DASHBOARD_2024, old_view=_OldInstitutionUserMetricsList.as_view(), diff --git a/api_tests/institutions/views/test_institution_summary_metrics.py b/api_tests/institutions/views/test_institution_summary_metrics.py index 8fee209b769..a2ceee7ff4e 100644 --- a/api_tests/institutions/views/test_institution_summary_metrics.py +++ b/api_tests/institutions/views/test_institution_summary_metrics.py @@ -1,12 +1,16 @@ import pytest import datetime +from waffle.testutils import override_flag +from osf.metrics import InstitutionProjectCounts + from api.base.settings.defaults import API_BASE from osf_tests.factories import ( + InstitutionFactory, AuthUserFactory, - InstitutionFactory ) -from osf.metrics import InstitutionProjectCounts +from osf.metrics.reports import InstitutionMonthlySummaryReport +from osf import features @pytest.mark.es_metrics @@ -92,3 +96,115 @@ def test_get(self, app, url, institution, user, admin): 'self': f'http://localhost:8000/v2/institutions/{institution._id}/metrics/summary/' } } + + +@pytest.mark.es_metrics +@pytest.mark.django_db +class TestNewInstitutionSummaryMetricsList: + @pytest.fixture(autouse=True) + def _waffled(self): + with override_flag(features.INSTITUTIONAL_DASHBOARD_2024, active=True): + yield + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def rando(self): + return AuthUserFactory() + + @pytest.fixture() + def institutional_admin(self, institution): + admin_user = AuthUserFactory() + institution.get_group('institutional_admins').user_set.add(admin_user) + return admin_user + + @pytest.fixture() + def unshown_reports(self, institution): + # Reports that should not be shown in the results + # Report from another institution + another_institution = InstitutionFactory() + _summary_report_factory('2024-08', another_institution) + # Old report from the same institution + _summary_report_factory('2024-07', institution) + _summary_report_factory('2018-02', institution) + + @pytest.fixture() + def reports(self, institution): + return [ + _summary_report_factory( + '2024-08', institution, + user_count=100, + public_project_count=50, + private_project_count=25, + public_registration_count=10, + embargoed_registration_count=5, + published_preprint_count=15, + public_file_count=20, + storage_byte_count=5000000000, + monthly_logged_in_user_count=80, + monthly_active_user_count=60, + ), + _summary_report_factory( + '2024-08', institution, + user_count=200, + public_project_count=150, + private_project_count=125, + public_registration_count=110, + embargoed_registration_count=105, + published_preprint_count=115, + public_file_count=120, + storage_byte_count=15000000000, + monthly_logged_in_user_count=180, + monthly_active_user_count=160, + ), + ] + + @pytest.fixture() + def url(self, institution): + return f'/{API_BASE}institutions/{institution._id}/metrics/summary/' + + def test_anon(self, app, url): + resp = app.get(url, expect_errors=True) + assert resp.status_code == 401 + + def test_rando(self, app, url, rando): + resp = app.get(url, auth=rando.auth, expect_errors=True) + assert resp.status_code == 403 + + def test_get_empty(self, app, url, institutional_admin): + resp = app.get(url, auth=institutional_admin.auth) + assert resp.status_code == 200 + assert resp.json['meta'] == {'version': '2.0'} + + def test_get_report(self, app, url, institutional_admin, institution, reports, unshown_reports): + resp = app.get(url, auth=institutional_admin.auth) + assert resp.status_code == 200 + + data = resp.json['data'] + + assert data['id'] == institution._id + assert data['type'] == 'institution-summary-metrics' + + attributes = data['attributes'] + assert attributes['user_count'] == 200 + assert attributes['public_project_count'] == 150 + assert attributes['private_project_count'] == 125 + assert attributes['public_registration_count'] == 110 + assert attributes['embargoed_registration_count'] == 105 + assert attributes['published_preprint_count'] == 115 + assert attributes['public_file_count'] == 120 + assert attributes['storage_byte_count'] == 15000000000 + assert attributes['monthly_logged_in_user_count'] == 180 + assert attributes['monthly_active_user_count'] == 160 + + +def _summary_report_factory(yearmonth, institution, **kwargs): + report = InstitutionMonthlySummaryReport( + report_yearmonth=yearmonth, + institution_id=institution._id, + **kwargs, + ) + report.save(refresh=True) + return report diff --git a/osf/metrics/reports.py b/osf/metrics/reports.py index cee4efc7c02..08d14867ae0 100644 --- a/osf/metrics/reports.py +++ b/osf/metrics/reports.py @@ -270,3 +270,18 @@ class InstitutionalUserReport(MonthlyReport): published_preprint_count = metrics.Integer() public_file_count = metrics.Long() storage_byte_count = metrics.Long() + + +class InstitutionMonthlySummaryReport(MonthlyReport): + UNIQUE_TOGETHER_FIELDS = ('report_yearmonth', 'institution_id', ) + institution_id = metrics.Keyword() + user_count = metrics.Integer() + public_project_count = metrics.Integer() + private_project_count = metrics.Integer() + public_registration_count = metrics.Integer() + embargoed_registration_count = metrics.Integer() + published_preprint_count = metrics.Integer() + storage_byte_count = metrics.Long() + public_file_count = metrics.Long() + monthly_logged_in_user_count = metrics.Long() + monthly_active_user_count = metrics.Long()