diff --git a/per/drf_views.py b/per/drf_views.py index 4a3d284e9..00cc257f8 100644 --- a/per/drf_views.py +++ b/per/drf_views.py @@ -3,7 +3,7 @@ import pytz from django.conf import settings from django.db import transaction -from django.db.models import Prefetch, Q +from django.db.models import Count, F, Prefetch, Q from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.utils.translation import get_language as django_get_language @@ -20,7 +20,7 @@ from rest_framework.response import Response from rest_framework.settings import api_settings -from api.models import Country +from api.models import Appeal, Country, Region from deployments.models import SectorTag from main.permissions import DenyGuestUserMutationPermission, DenyGuestUserPermission from main.utils import SpreadSheetContentNegotiation @@ -82,6 +82,7 @@ OpsLearningInSerializer, OpsLearningOrganizationTypeSerializer, OpsLearningSerializer, + OpsLearningStatSerializer, OpsLearningSummarySerializer, PerAssessmentSerializer, PerDocumentUploadSerializer, @@ -921,6 +922,78 @@ def summary(self, request): ) return response.Response(OpsLearningSummarySerializer(ops_learning_summary_instance).data) + @extend_schema( + request=None, + filters=True, + responses=OpsLearningStatSerializer, + ) + @action( + detail=False, + methods=["GET"], + permission_classes=[DenyGuestUserMutationPermission, OpsLearningPermission], + url_path="stats", + ) + def stats(self, request): + """ + Get the Ops Learning stats based on the filters + """ + queryset = self.filter_queryset(self.get_queryset()).filter(is_validated=True) + ops_data = queryset.aggregate( + operations_included=Count("appeal_code", distinct=True), + learning_extracts=Count("id", distinct=True), + sector_covered=Count("sector_validated", distinct=True), + source_used=Count("appeal_code__appealdocument", distinct=True), + ) + + learning_by_sector_qs = ( + SectorTag.objects.filter(validated_sectors__in=queryset, title__isnull=False) + .annotate(sector_id=F("id"), count=Count("validated_sectors", distinct=True)) + .values("sector_id", "title", "count") + ) + + # NOTE: Queryset is unbounded, we may need to add some start_date filter. + sources_overtime_qs = ( + Appeal.objects.filter(opslearning__in=queryset) + .annotate( + type=F("atype"), + date=F("start_date"), + count=Count("appealdocument", distinct=True), + ) + .values("type", "date", "count") + ) + + learning_by_region_qs = ( + Region.objects.filter(appeal__opslearning__in=queryset) + .annotate( + region_id=F("id"), + region_name=F("label"), + count=Count("appeal__opslearning", distinct=True), + ) + .values("region_id", "region_name", "count") + ) + + learning_by_country_qs = ( + Country.objects.filter(appeal__opslearning__in=queryset) + .annotate( + country_id=F("id"), + country_name=F("name"), + count=Count("appeal__opslearning", distinct=True), + ) + .values("country_id", "country_name", "count") + ) + + data = { + "operations_included": ops_data["operations_included"], + "learning_extracts": ops_data["learning_extracts"], + "sectors_covered": ops_data["sector_covered"], + "sources_used": ops_data["source_used"], + "learning_by_region": learning_by_region_qs, + "learning_by_sector": learning_by_sector_qs, + "sources_overtime": sources_overtime_qs, + "learning_by_country": learning_by_country_qs, + } + return response.Response(OpsLearningStatSerializer(data).data) + class PerDocumentUploadViewSet(viewsets.ModelViewSet): queryset = PerDocumentUpload.objects.all() diff --git a/per/factories.py b/per/factories.py index d9673ea92..f27181bb9 100644 --- a/per/factories.py +++ b/per/factories.py @@ -3,6 +3,7 @@ import factory from factory import fuzzy +from api.models import Appeal, AppealDocument from deployments.factories.project import SectorTagFactory from per.models import ( AssessmentType, @@ -105,6 +106,11 @@ class Meta: model = FormPrioritization +class AppealFactory(factory.django.DjangoModelFactory): + class Meta: + model = Appeal + + class OpsLearningFactory(factory.django.DjangoModelFactory): learning = fuzzy.FuzzyText(length=50) @@ -141,3 +147,10 @@ class OpsLearningComponentCacheResponseFactory(factory.django.DjangoModelFactory class Meta: model = OpsLearningComponentCacheResponse + + +class AppealDocumentFactory(factory.django.DjangoModelFactory): + class Meta: + model = AppealDocument + + appeal = factory.SubFactory(AppealFactory) diff --git a/per/serializers.py b/per/serializers.py index b2e699e15..2233347d3 100644 --- a/per/serializers.py +++ b/per/serializers.py @@ -969,6 +969,7 @@ class Meta: "atype", "event_details", "country", + "start_date", ) @@ -1253,3 +1254,43 @@ class Meta: "id", "title", ] + + +class LearningByRegionSerializer(serializers.Serializer): + region_id = serializers.IntegerField(required=True) + region_name = serializers.CharField(required=True) + count = serializers.IntegerField(required=True) + + +class LearningByCountrySerializer(serializers.Serializer): + country_id = serializers.IntegerField(required=True) + country_name = serializers.CharField(required=True) + count = serializers.IntegerField(required=True) + + +class LearningBySectorSerializer(serializers.Serializer): + sector_id = serializers.IntegerField(required=True) + title = serializers.CharField(required=True) + count = serializers.IntegerField(required=True) + + +class LearningSourcesOvertimeSerializer(serializers.Serializer): + type = serializers.IntegerField(required=True) + type_display = serializers.SerializerMethodField(read_only=True) + date = serializers.DateTimeField(required=True) + count = serializers.IntegerField(required=True) + + def get_type_display(self, obj): + type = obj.get("type") + return AppealType(type).label + + +class OpsLearningStatSerializer(serializers.Serializer): + operations_included = serializers.IntegerField(required=True) + learning_extracts = serializers.IntegerField(required=True) + sectors_covered = serializers.IntegerField(required=True) + sources_used = serializers.IntegerField(required=True) + learning_by_region = LearningByRegionSerializer(many=True) + learning_by_country = LearningByCountrySerializer(many=True) + learning_by_sector = LearningBySectorSerializer(many=True) + sources_overtime = LearningSourcesOvertimeSerializer(many=True) diff --git a/per/test_views.py b/per/test_views.py index 8860e5501..f113d2f4b 100644 --- a/per/test_views.py +++ b/per/test_views.py @@ -2,14 +2,19 @@ from unittest import mock from api.factories.country import CountryFactory +from api.factories.region import RegionFactory from api.models import AppealType from main.test_case import APITestCase from per.factories import ( + AppealDocumentFactory, + AppealFactory, FormAreaFactory, FormComponentFactory, FormPrioritizationFactory, + OpsLearningFactory, OverviewFactory, PerWorkPlanFactory, + SectorTagFactory, ) from .models import WorkPlanStatus @@ -224,3 +229,60 @@ def test_summary_generation(self, generate_summary): } self.check_response_id(url=url, data=filters) self.assertTrue(generate_summary.assert_called) + + +class OpsLearningStatsTestCase(APITestCase): + + def setUp(self): + super().setUp() + self.region = RegionFactory.create(label="Region A") + self.country = CountryFactory.create(region=self.region, name="Country A") + + self.sector1 = SectorTagFactory.create(title="Sector 1") + self.sector2 = SectorTagFactory.create(title="Sector 2") + + self.appeal1 = AppealFactory.create( + region=self.region, country=self.country, code="APP001", atype=0, start_date="2023-01-01" + ) + self.appeal2 = AppealFactory.create( + region=self.region, country=self.country, code="APP002", atype=1, start_date="2023-02-01" + ) + + AppealDocumentFactory.create(appeal=self.appeal1) + AppealDocumentFactory.create(appeal=self.appeal2) + + self.ops_learning1 = OpsLearningFactory.create(is_validated=True, appeal_code=self.appeal1) + self.ops_learning1.sector_validated.set([self.sector1]) + + self.ops_learning2 = OpsLearningFactory.create(is_validated=True, appeal_code=self.appeal2) + self.ops_learning2.sector_validated.set([self.sector2]) + + self.ops_learning3 = OpsLearningFactory.create(is_validated=False, appeal_code=self.appeal2) + self.ops_learning3.sector_validated.set([self.sector2]) + + def test_ops_learning_stats(self): + url = "/api/v2/ops-learning/stats/" + response = self.client.get(url) + + self.assert_200(response) + + # Updated counts based on validated entries + self.assertEqual(response.data["operations_included"], 2) + self.assertEqual(response.data["sources_used"], 2) + self.assertEqual(response.data["learning_extracts"], 2) + self.assertEqual(response.data["sectors_covered"], 2) + + # Validate learning by region + region_data = response.data["learning_by_region"] + self.assertEqual(region_data[0]["count"], 2) + + # Validate learning by sector + sector_data = response.data["learning_by_sector"] + self.assertEqual(len(sector_data), 2) + + # Validate learning by country + country_data = response.data["learning_by_country"] + self.assertEqual(len(country_data), 1) + + sources_overtime = response.data["sources_overtime"] + self.assertEqual(len(sources_overtime), 2)