From 287452ef87788b8109e1ab52bf1dba77dcc6f93e Mon Sep 17 00:00:00 2001 From: Szabo Zoltan Date: Tue, 2 Dec 2025 14:50:24 +0100 Subject: [PATCH 1/5] PER data-fetcher functionality to backend side, v1.0 --- main/urls.py | 5 +++ per/drf_views.py | 106 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/main/urls.py b/main/urls.py index f4a31cb42..472eaf35a 100644 --- a/main/urls.py +++ b/main/urls.py @@ -117,8 +117,12 @@ router.register(r"per-overview", per_views.PerOverviewViewSet, basename="new_per") router.register(r"per-assessment", per_views.FormAssessmentViewSet, basename="per-assessent") router.register(r"public-per-assessment", per_views.PublicFormAssessmentViewSet, basename="public-per-assessent") +router.register(r"public-per-assessment-2", per_views.PublicFormAssessmentViewSet2, basename="public-per-assessent-2") router.register(r"per-prioritization", per_views.FormPrioritizationViewSet, basename="per-priorirization") router.register(r"public-per-prioritization", per_views.PublicFormPrioritizationViewSet, basename="public-per-priorirization") +router.register( + r"public-per-prioritization-2", per_views.PublicFormPrioritizationViewSet2, basename="public-per-priorirization-2" +) router.register(r"per-work-plan", per_views.NewPerWorkPlanViewSet) router.register(r"per-formanswer", per_views.FormAnswerViewset, basename="per-formanswer") router.register(r"per-formarea", per_views.FormAreaViewset, basename="per-formarea") @@ -129,6 +133,7 @@ router.register(r"per-file", per_views.PerFileViewSet, basename="per-file") router.register(r"per-process-status", per_views.PerProcessStatusViewSet, basename="per-process-status") router.register(r"public-per-process-status", per_views.PublicPerProcessStatusViewSet, basename="public-per-process-status") +router.register(r"public-per-process-status-2", per_views.PublicPerProcessStatusViewSet2, basename="public-per-process-status-2") router.register(r"perdocs", per_views.PERDocsViewset) router.register(r"per-country", per_views.PerCountryViewSet, basename="per-country") router.register(r"public-per-stats", per_views.CountryPublicPerStatsViewset, basename="public_country_per_stats") diff --git a/per/drf_views.py b/per/drf_views.py index 8d9a12639..fb37e0fae 100644 --- a/per/drf_views.py +++ b/per/drf_views.py @@ -100,6 +100,30 @@ UserPerCountrySerializer, ) +# Helpers for transformed "-2" endpoints +AREA_NAMES = { + 1: "Policy Strategy and Standards", + 2: "Analysis and planning", + 3: "Operational capacity", + 4: "Coordination", + 5: "Operations support", +} + +AFFIRMATIVE_WORDS = {"yes", "si", "sí", "oui", "da", "ja", "sim", "aye", "yep", "igen", "hai", "evet", "是", "はい", "예", "نعم"} + + +def _contains_affirmative(text: str) -> bool: + if not text or not isinstance(text, str): + return False + try: + import unicodedata + + normalized = unicodedata.normalize("NFD", text.lower()) + normalized = "".join(ch for ch in normalized if unicodedata.category(ch) != "Mn") + except Exception: + normalized = text.lower() + return any(word in normalized for word in AFFIRMATIVE_WORDS) + class PERDocsFilter(filters.FilterSet): id = filters.NumberFilter(field_name="id", lookup_expr="exact") @@ -118,6 +142,7 @@ class PERDocsViewset(viewsets.ReadOnlyModelViewSet): queryset = NiceDocument.objects.all() authentication_classes = (TokenAuthentication,) permission_classes = (IsAuthenticated,) + get_request_user_regions = RegionRestrictedAdmin.get_request_user_regions get_filtered_queryset = RegionRestrictedAdmin.get_filtered_queryset filterset_class = PERDocsFilter @@ -126,6 +151,7 @@ def get_queryset(self): queryset = NiceDocument.objects.all() cond1 = Q() cond2 = Q() + cond3 = Q() if "new" in self.request.query_params.keys(): last_duedate = settings.PER_LAST_DUEDATE @@ -557,6 +583,34 @@ class PublicFormPrioritizationViewSet(viewsets.ReadOnlyModelViewSet): ordering_fields = "__all__" +class PublicFormPrioritizationViewSet2(PublicFormPrioritizationViewSet): + """Adds simplified prioritized components array (mirrors JS).""" + + def list(self, request, *args, **kwargs): + resp = super().list(request, *args, **kwargs) + data = resp.data + results = data.get("results") if isinstance(data, dict) else data + if results: + for item in results: + pcs = [] + for resp_item in item.get("prioritized_action_responses", []) or []: + cd = resp_item.get("component_details") or {} + pcs.append( + { + "componentId": resp_item.get("component"), + "componentTitle": cd.get("title"), + "areaTitle": ( + cd.get("area_title") or AREA_NAMES.get(cd.get("area")) + if isinstance(cd.get("area"), int) + else cd.get("area_title") + ), + "description": cd.get("description"), + } + ) + item["components"] = pcs + return Response(data) + + class PerOptionsView(views.APIView): permission_classes = [permissions.IsAuthenticated, DenyGuestUserPermission] ordering_fields = "__all__" @@ -596,6 +650,23 @@ def get_queryset(self): return Overview.objects.order_by("country", "-assessment_number", "-date_of_assessment") +class PublicPerProcessStatusViewSet2(PublicPerProcessStatusViewSet): + """Phase display normalization (mirrors JS).""" + + def list(self, request, *args, **kwargs): + resp = super().list(request, *args, **kwargs) + data = resp.data + results = data.get("results") if isinstance(data, dict) else data + if results: + for row in results: + pd = row.get("phase_display") + if pd == "Action And Accountability": + row["phase_display"] = "Action & accountability" + elif pd == "WorkPlan": + row["phase_display"] = "Workplan" + return Response(data) + + class FormAssessmentViewSet(viewsets.ModelViewSet): serializer_class = PerAssessmentSerializer permission_classes = [permissions.IsAuthenticated, PerGeneralPermission, DenyGuestUserPermission] @@ -613,6 +684,41 @@ def get_queryset(self): return PerAssessment.objects.select_related("overview") +class PublicFormAssessmentViewSet2(PublicFormAssessmentViewSet): + """Adds affirmative flags & dashboard components (mirrors JS).""" + + def list(self, request, *args, **kwargs): + resp = super().list(request, *args, **kwargs) + data = resp.data + results = data.get("results") if isinstance(data, dict) else data + if results: + for assessment in results: + components = [] + for area in assessment.get("area_responses", []) or []: + for comp in area.get("component_responses", []) or []: + comp["urban_considerations_simplified"] = _contains_affirmative(comp.get("urban_considerations")) + comp["epi_considerations_simplified"] = _contains_affirmative(comp.get("epi_considerations")) + comp["migration_considerations_simplified"] = _contains_affirmative(comp.get("migration_considerations")) + comp["climate_environmental_considerations_simplified"] = _contains_affirmative( + comp.get("climate_environmental_considerations") + ) + cd = comp.get("component_details") or {} + rd = comp.get("rating_details") or {} + components.append( + { + "component_id": comp.get("component"), + "component_name": cd.get("title") or "", + "component_num": cd.get("component_num"), + "area_id": cd.get("area"), + "area_name": AREA_NAMES.get(cd.get("area")) if isinstance(cd.get("area"), int) else None, + "rating_value": rd.get("value") or 0, + "rating_title": rd.get("title") or "", + } + ) + assessment["components"] = components + return Response(data) + + class PerFileViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): permission_classes = [permissions.IsAuthenticated, DenyGuestUserPermission] serializer_class = PerFileSerializer From 98e9f5e9bbeaa9e4394a84791a99aa90e5e77ed3 Mon Sep 17 00:00:00 2001 From: Szabo Zoltan Date: Tue, 2 Dec 2025 16:52:54 +0100 Subject: [PATCH 2/5] PER data-fetcher functionality to backend side, v1.1 --- main/urls.py | 4 ++ per/drf_views.py | 143 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/main/urls.py b/main/urls.py index 472eaf35a..77f7edc8c 100644 --- a/main/urls.py +++ b/main/urls.py @@ -222,6 +222,10 @@ url(r"^api/v2/secondarysector", ProjectSecondarySectors.as_view()), url(r"^api/v2/projectstatus", ProjectStatuses.as_view()), url(r"^api/v2/learningtype", LearningTypes.as_view()), + # Consolidated PER endpoints + url(r"^api/v2/per-map-data", per_views.PerMapDataView.as_view()), + url(r"^api/v2/per-assessments-processed", per_views.PerAssessmentsProcessedView.as_view()), + url(r"^api/v2/per-dashboard-data", per_views.PerDashboardDataView.as_view()), # url(r"^api/v2/create_field_report/", api_views.CreateFieldReport.as_view()), # url(r"^api/v2/update_field_report/(?P\d+)/", api_views.UpdateFieldReport.as_view()), url(r"^get_auth_token", GetAuthToken.as_view()), diff --git a/per/drf_views.py b/per/drf_views.py index fb37e0fae..71ce2ddbf 100644 --- a/per/drf_views.py +++ b/per/drf_views.py @@ -719,6 +719,149 @@ def list(self, request, *args, **kwargs): return Response(data) +# Consolidated public endpoints (map-data, assessments-processed, dashboard-data) +class PerMapDataView(views.APIView): + """Public consolidated PER map data. + + Joins latest Overview per country with minimal country info and normalized phase display. + """ + + def get(self, request): + latest_overviews = ( + Overview.objects.order_by("country_id", "-assessment_number", "-date_of_assessment") + .distinct("country_id") + .select_related("country") + ) + items = [] + for ov in latest_overviews: + phase_display = ov.get_phase_display() if hasattr(ov, "get_phase_display") else getattr(ov, "phase_display", None) + if phase_display == "Action And Accountability": + phase_display = "Action & accountability" + elif phase_display == "WorkPlan": + phase_display = "Workplan" + items.append( + { + "country_id": ov.country_id, + "country_name": ov.country.name if ov.country else None, + "country_iso3": getattr(ov.country, "iso3", None), + "phase": phase_display, + "assessment_number": ov.assessment_number, + "date_of_assessment": ov.date_of_assessment, + } + ) + return Response({"results": items}) + + +class PerAssessmentsProcessedView(views.APIView): + """Public consolidated PER assessments processed data. + + Flattens component considerations flags and rating info per assessment for downstream use. + """ + + def get(self, request): + assessments = PerAssessment.objects.select_related("overview", "overview__country").prefetch_related( + Prefetch( + "area_responses", + queryset=AreaResponse.objects.prefetch_related( + Prefetch( + "component_response", + queryset=FormComponentResponse.objects.prefetch_related("question_responses"), + ) + ), + ) + ) + results = [] + for a in assessments: + components = [] + for ar in a.area_responses.all(): + for cr in ar.component_response.all(): + cd = getattr(cr, "component_details", None) + rd = getattr(cr, "rating_details", None) + # When accessed via serializer, details exist; otherwise construct minimally + components.append( + { + "component_id": getattr(cr, "component_id", None), + "urban_considerations_simplified": _contains_affirmative(getattr(cr, "urban_considerations", "")), + "epi_considerations_simplified": _contains_affirmative(getattr(cr, "epi_considerations", "")), + "migration_considerations_simplified": _contains_affirmative( + getattr(cr, "migration_considerations", "") + ), + "climate_environmental_considerations_simplified": _contains_affirmative( + getattr(cr, "climate_environmental_considerations", "") + ), + "component_name": (cd.title if cd else getattr(cr.component, "title", None)), + "component_num": (cd.component_num if cd else getattr(cr.component, "component_num", None)), + "area_id": (cd.area if cd else getattr(cr.component.area, "id", None)), + "area_name": ( + AREA_NAMES.get(cd.area) + if (cd and isinstance(cd.area, int)) + else getattr(getattr(cr.component, "area", None), "name", None) + ), + "rating_value": (rd.value if rd else getattr(getattr(cr, "rating", None), "value", None)), + "rating_title": (rd.title if rd else getattr(getattr(cr, "rating", None), "title", None)), + } + ) + results.append( + { + "assessment_id": a.id, + "country_id": getattr(a.overview, "country_id", None), + "country_name": getattr(getattr(a.overview, "country", None), "name", None), + "components": components, + } + ) + return Response({"results": results}) + + +class PerDashboardDataView(views.APIView): + """Public consolidated PER dashboard data. + + Groups latest per country overview and attaches lightweight assessment entries. + """ + + def get(self, request): + latest_overviews = ( + Overview.objects.order_by("country_id", "-assessment_number", "-date_of_assessment") + .distinct("country_id") + .select_related("country") + ) + # Map assessments by country for quick attach + assessments_by_country = {} + for a in PerAssessment.objects.select_related("overview"): + cid = getattr(a.overview, "country_id", None) + if cid is None: + continue + assessments_by_country.setdefault(cid, []).append( + { + "assessment_id": a.id, + "assessment_number": getattr(a.overview, "assessment_number", None), + "date_of_assessment": getattr(a.overview, "date_of_assessment", None), + } + ) + items = [] + for ov in latest_overviews: + phase_display = ov.get_phase_display() if hasattr(ov, "get_phase_display") else getattr(ov, "phase_display", None) + if phase_display == "Action And Accountability": + phase_display = "Action & accountability" + elif phase_display == "WorkPlan": + phase_display = "Workplan" + items.append( + { + "country_id": ov.country_id, + "country_name": ov.country.name if ov.country else None, + "country_iso3": getattr(ov.country, "iso3", None), + "phase": phase_display, + "assessment_number": ov.assessment_number, + "date_of_assessment": ov.date_of_assessment, + "countryAssessments": sorted( + assessments_by_country.get(ov.country_id, []), + key=lambda x: (x["date_of_assessment"] or datetime.min), + reverse=True, + ), + } + ) + return Response({"results": items}) + + class PerFileViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet): permission_classes = [permissions.IsAuthenticated, DenyGuestUserPermission] serializer_class = PerFileSerializer From 2397d393c40c2f57786afaf6738a92ad876b2df2 Mon Sep 17 00:00:00 2001 From: Szabo Zoltan Date: Wed, 3 Dec 2025 11:39:12 +0100 Subject: [PATCH 3/5] PER data-fetcher functionality to backend side, v1.2 --- per/drf_views.py | 117 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 8 deletions(-) diff --git a/per/drf_views.py b/per/drf_views.py index 71ce2ddbf..9ecefb7c5 100644 --- a/per/drf_views.py +++ b/per/drf_views.py @@ -730,23 +730,124 @@ def get(self, request): latest_overviews = ( Overview.objects.order_by("country_id", "-assessment_number", "-date_of_assessment") .distinct("country_id") - .select_related("country") + .select_related("country", "type_of_assessment", "country__region") ) items = [] for ov in latest_overviews: - phase_display = ov.get_phase_display() if hasattr(ov, "get_phase_display") else getattr(ov, "phase_display", None) + # Normalize phase display and keep raw phase if available + phase_display = getattr(ov, "phase_display", None) + normalized_phase_display = phase_display if phase_display == "Action And Accountability": - phase_display = "Action & accountability" + normalized_phase_display = "Action & accountability" elif phase_display == "WorkPlan": - phase_display = "Workplan" + normalized_phase_display = "Workplan" + + # Attach components from latest assessment tied to the overview + components = [] + epi_considerations = False + climate_considerations = False + urban_considerations = False + migration_considerations = False + latest_assessment = ( + PerAssessment.objects.filter(overview_id=getattr(ov, "id", None)) + .prefetch_related( + Prefetch( + "area_responses", + queryset=AreaResponse.objects.prefetch_related( + Prefetch( + "component_response", + queryset=FormComponentResponse.objects.select_related("component", "component__area", "rating"), + ) + ), + ) + ) + .first() + ) + if latest_assessment: + for ar in latest_assessment.area_responses.all(): + for cr in ar.component_response.all(): + # Flags + epi = _contains_affirmative(getattr(cr, "epi_considerations", "")) + urb = _contains_affirmative(getattr(cr, "urban_considerations", "")) + clim = _contains_affirmative(getattr(cr, "climate_environmental_considerations", "")) + mig = _contains_affirmative(getattr(cr, "migration_considerations", "")) + epi_considerations = epi_considerations or epi + urban_considerations = urban_considerations or urb + climate_considerations = climate_considerations or clim + migration_considerations = migration_considerations or mig + + comp = getattr(cr, "component", None) + area = getattr(comp, "area", None) if comp else None + rating = getattr(cr, "rating", None) + # Resolve area name via AREA_NAMES when area_num is an int + area_num_val = getattr(area, "area_num", None) + components.append( + { + "component_id": getattr(comp, "id", None) or getattr(cr, "component_id", None), + "component_name": getattr(comp, "title", None) + or getattr(comp, "description_en", None) + or getattr(comp, "description", None), + "component_num": getattr(comp, "component_num", None), + "area_id": getattr(area, "id", None), + "area_name": ( + AREA_NAMES.get(area_num_val) if isinstance(area_num_val, int) else getattr(area, "name", None) + ), + "rating_value": getattr(rating, "value", None), + "rating_title": getattr(rating, "title", None), + } + ) + + # Prioritized components (workplan/prioritization) + prioritized_components = [] + fp = FormPrioritization.objects.filter(overview_id=getattr(ov, "id", None)).first() + if fp: + for pac in fp.prioritized_action_responses.exclude(component_id=14).select_related( + "component", "component__area" + ): + pc_comp = pac.component + pc_area = pc_comp.area if pc_comp else None + area_num_val2 = getattr(pc_area, "area_num", None) + prioritized_components.append( + { + "componentId": getattr(pc_comp, "id", None), + "componentTitle": getattr(pc_comp, "title", None) + or getattr(pc_comp, "description_en", None) + or getattr(pc_comp, "description", None), + "areaTitle": ( + AREA_NAMES.get(area_num_val2) + if isinstance(area_num_val2, int) + else getattr(pc_area, "name", None) + ), + "description": getattr(pc_comp, "description", None) or getattr(pc_comp, "description_en", None), + } + ) + items.append( { - "country_id": ov.country_id, - "country_name": ov.country.name if ov.country else None, - "country_iso3": getattr(ov.country, "iso3", None), - "phase": phase_display, + "id": getattr(ov, "id", None), "assessment_number": ov.assessment_number, "date_of_assessment": ov.date_of_assessment, + "country_id": getattr(ov, "country_id", None), + "country_name": ov.country.name if ov.country else None, + "phase": getattr(ov, "phase", None), + "phase_display": normalized_phase_display, + "type_of_assessment": getattr(ov.type_of_assessment, "id", None), + "type_of_assessment_name": getattr(ov.type_of_assessment, "name", None), + "country_iso3": getattr(ov.country, "iso3", None), + "region_id": getattr(getattr(ov.country, "region", None), "id", None), + "region_name": getattr(getattr(ov.country, "region", None), "label", None), + "latitude": ( + ov.country.centroid.y if getattr(ov.country, "centroid", None) else getattr(ov.country, "latitude", None) + ), + "longitude": ( + ov.country.centroid.x if getattr(ov.country, "centroid", None) else getattr(ov.country, "longitude", None) + ), + "updated_at": getattr(ov, "updated_at", None), + "prioritized_components": prioritized_components, + "epi_considerations": epi_considerations, + "climate_environmental_considerations": climate_considerations, + "urban_considerations": urban_considerations, + "components": components, } ) return Response({"results": items}) From 4f6a14a590ea5854f2a03b56a8dc344a8ef81445 Mon Sep 17 00:00:00 2001 From: Szabo Zoltan Date: Wed, 3 Dec 2025 15:40:31 +0100 Subject: [PATCH 4/5] Update docs and assets --- .gitmodules | 2 +- assets | 2 +- docs/go-artifacts.md | 71 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 0957519ac..0837dda98 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "assets"] path = assets - url = https://github.com/IFRCGo/go-api-artifacts + url = git@github.com:IFRCGo/go-api-artifacts.git diff --git a/assets b/assets index d6b617c5e..aa99e1ca2 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit d6b617c5efdd857d398ef5ab569509ae32e8fa18 +Subproject commit aa99e1ca2496fd8c8273c21988191f80821ec73a diff --git a/docs/go-artifacts.md b/docs/go-artifacts.md index b75b264e8..4c1eec982 100644 --- a/docs/go-artifacts.md +++ b/docs/go-artifacts.md @@ -40,3 +40,74 @@ docker compose run --rm serve ./manage.py spectacular --file .assets/openapi-sch - In `go-api` - **Update the submodule reference** in your `go-api` PR. - In the corresponding **`go-api` Pull Request**, include the link to the `go-api-artifacts` PR as a *related PR* so reviewers can track both changes together. + +## Submodule Pointer Workflow + +Keep CI green by ensuring the parent repo points to a submodule commit that exists on the remote. + +- Update submodule content and push: + ```bash + cd assets + git checkout -b update-artifacts + # make changes (e.g., regenerate schema) + git add -A + git commit -m "Update artifacts" + git push origin update-artifacts + # open PR in IFRCGo/go-api-artifacts and merge to main + git checkout main && git pull + cd - + ``` +- Record new submodule commit in parent: + ```bash + # ensure the submodule worktree is on the merged commit + cd assets && git checkout main && git pull && cd - + git add assets + git commit -m "Update assets submodule pointer" + git push origin + ``` +- GitHub Actions checkout settings (recommended): + ```yaml + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + ``` + +Notes +- Avoid amending/rebasing submodule commits that the parent already references; make a new commit instead. +- Ensure submodule commit is on `origin/main` (or a ref CI can fetch) before updating the parent pointer. + +## Submodule Commands Cheat Sheet + +Common commands you’ll use with `assets` submodule: + +- Initialize submodules after clone: + ```bash + git submodule update --init --recursive + ``` +- Sync `.gitmodules` config to local submodule metadata: + ```bash + git submodule sync + ``` +- Move submodule to latest commit from its remote tracking branch: + ```bash + git submodule update --remote assets + git add assets + git commit -m "Sync assets to latest remote commit" + ``` +- Pin submodule to a specific commit (e.g., after checkout): + ```bash + cd assets + git checkout + cd - + git add assets + git commit -m "Update assets submodule pointer" + ``` +- Switch submodule remote to SSH (avoid HTTPS prompts): + ```bash + git config -f .gitmodules submodule.assets.url git@github.com:IFRCGo/go-api-artifacts.git + git submodule sync + cd assets && git remote set-url origin git@github.com:IFRCGo/go-api-artifacts.git && cd - + git add .gitmodules assets + git commit -m "Use SSH for artifacts submodule" + ``` From a0d1f0da38c191ce8ae53381dae3e0fe6b450adb Mon Sep 17 00:00:00 2001 From: Szabo Zoltan Date: Thu, 4 Dec 2025 14:36:03 +0100 Subject: [PATCH 5/5] PER data-fetcher functionality to backend side, v1.3 --- assets | 2 +- main/urls.py | 5 - per/drf_views.py | 358 +++++++++++++++++++++++++++-------------------- 3 files changed, 206 insertions(+), 159 deletions(-) diff --git a/assets b/assets index aa99e1ca2..991d1b5eb 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit aa99e1ca2496fd8c8273c21988191f80821ec73a +Subproject commit 991d1b5eb87908023fec96f852bce2ff14dbca09 diff --git a/main/urls.py b/main/urls.py index 77f7edc8c..e329be302 100644 --- a/main/urls.py +++ b/main/urls.py @@ -117,12 +117,8 @@ router.register(r"per-overview", per_views.PerOverviewViewSet, basename="new_per") router.register(r"per-assessment", per_views.FormAssessmentViewSet, basename="per-assessent") router.register(r"public-per-assessment", per_views.PublicFormAssessmentViewSet, basename="public-per-assessent") -router.register(r"public-per-assessment-2", per_views.PublicFormAssessmentViewSet2, basename="public-per-assessent-2") router.register(r"per-prioritization", per_views.FormPrioritizationViewSet, basename="per-priorirization") router.register(r"public-per-prioritization", per_views.PublicFormPrioritizationViewSet, basename="public-per-priorirization") -router.register( - r"public-per-prioritization-2", per_views.PublicFormPrioritizationViewSet2, basename="public-per-priorirization-2" -) router.register(r"per-work-plan", per_views.NewPerWorkPlanViewSet) router.register(r"per-formanswer", per_views.FormAnswerViewset, basename="per-formanswer") router.register(r"per-formarea", per_views.FormAreaViewset, basename="per-formarea") @@ -133,7 +129,6 @@ router.register(r"per-file", per_views.PerFileViewSet, basename="per-file") router.register(r"per-process-status", per_views.PerProcessStatusViewSet, basename="per-process-status") router.register(r"public-per-process-status", per_views.PublicPerProcessStatusViewSet, basename="public-per-process-status") -router.register(r"public-per-process-status-2", per_views.PublicPerProcessStatusViewSet2, basename="public-per-process-status-2") router.register(r"perdocs", per_views.PERDocsViewset) router.register(r"per-country", per_views.PerCountryViewSet, basename="per-country") router.register(r"public-per-stats", per_views.CountryPublicPerStatsViewset, basename="public_country_per_stats") diff --git a/per/drf_views.py b/per/drf_views.py index 9ecefb7c5..6900a9fed 100644 --- a/per/drf_views.py +++ b/per/drf_views.py @@ -125,6 +125,27 @@ def _contains_affirmative(text: str) -> bool: return any(word in normalized for word in AFFIRMATIVE_WORDS) +def _phase_display_from_int(phase: int | None, existing_display: str | None = None) -> str | None: + """Return normalized phase display using Overview.Phase IntegerChoices. + + Uses the IntegerChoices label, then normalizes: + - "WorkPlan" -> "Workplan" + - "Action And Accountability" -> "Action & accountability" + """ + label = None + try: + if isinstance(phase, int): + label = Overview.Phase(phase).label # from IntegerChoices + except Exception: + label = None + disp = label or existing_display + if disp == "Action And Accountability": + return "Action & accountability" + if disp == "WorkPlan": + return "Workplan" + return disp + + class PERDocsFilter(filters.FilterSet): id = filters.NumberFilter(field_name="id", lookup_expr="exact") @@ -583,34 +604,6 @@ class PublicFormPrioritizationViewSet(viewsets.ReadOnlyModelViewSet): ordering_fields = "__all__" -class PublicFormPrioritizationViewSet2(PublicFormPrioritizationViewSet): - """Adds simplified prioritized components array (mirrors JS).""" - - def list(self, request, *args, **kwargs): - resp = super().list(request, *args, **kwargs) - data = resp.data - results = data.get("results") if isinstance(data, dict) else data - if results: - for item in results: - pcs = [] - for resp_item in item.get("prioritized_action_responses", []) or []: - cd = resp_item.get("component_details") or {} - pcs.append( - { - "componentId": resp_item.get("component"), - "componentTitle": cd.get("title"), - "areaTitle": ( - cd.get("area_title") or AREA_NAMES.get(cd.get("area")) - if isinstance(cd.get("area"), int) - else cd.get("area_title") - ), - "description": cd.get("description"), - } - ) - item["components"] = pcs - return Response(data) - - class PerOptionsView(views.APIView): permission_classes = [permissions.IsAuthenticated, DenyGuestUserPermission] ordering_fields = "__all__" @@ -650,23 +643,6 @@ def get_queryset(self): return Overview.objects.order_by("country", "-assessment_number", "-date_of_assessment") -class PublicPerProcessStatusViewSet2(PublicPerProcessStatusViewSet): - """Phase display normalization (mirrors JS).""" - - def list(self, request, *args, **kwargs): - resp = super().list(request, *args, **kwargs) - data = resp.data - results = data.get("results") if isinstance(data, dict) else data - if results: - for row in results: - pd = row.get("phase_display") - if pd == "Action And Accountability": - row["phase_display"] = "Action & accountability" - elif pd == "WorkPlan": - row["phase_display"] = "Workplan" - return Response(data) - - class FormAssessmentViewSet(viewsets.ModelViewSet): serializer_class = PerAssessmentSerializer permission_classes = [permissions.IsAuthenticated, PerGeneralPermission, DenyGuestUserPermission] @@ -684,41 +660,6 @@ def get_queryset(self): return PerAssessment.objects.select_related("overview") -class PublicFormAssessmentViewSet2(PublicFormAssessmentViewSet): - """Adds affirmative flags & dashboard components (mirrors JS).""" - - def list(self, request, *args, **kwargs): - resp = super().list(request, *args, **kwargs) - data = resp.data - results = data.get("results") if isinstance(data, dict) else data - if results: - for assessment in results: - components = [] - for area in assessment.get("area_responses", []) or []: - for comp in area.get("component_responses", []) or []: - comp["urban_considerations_simplified"] = _contains_affirmative(comp.get("urban_considerations")) - comp["epi_considerations_simplified"] = _contains_affirmative(comp.get("epi_considerations")) - comp["migration_considerations_simplified"] = _contains_affirmative(comp.get("migration_considerations")) - comp["climate_environmental_considerations_simplified"] = _contains_affirmative( - comp.get("climate_environmental_considerations") - ) - cd = comp.get("component_details") or {} - rd = comp.get("rating_details") or {} - components.append( - { - "component_id": comp.get("component"), - "component_name": cd.get("title") or "", - "component_num": cd.get("component_num"), - "area_id": cd.get("area"), - "area_name": AREA_NAMES.get(cd.get("area")) if isinstance(cd.get("area"), int) else None, - "rating_value": rd.get("value") or 0, - "rating_title": rd.get("title") or "", - } - ) - assessment["components"] = components - return Response(data) - - # Consolidated public endpoints (map-data, assessments-processed, dashboard-data) class PerMapDataView(views.APIView): """Public consolidated PER map data. @@ -734,13 +675,8 @@ def get(self, request): ) items = [] for ov in latest_overviews: - # Normalize phase display and keep raw phase if available - phase_display = getattr(ov, "phase_display", None) - normalized_phase_display = phase_display - if phase_display == "Action And Accountability": - normalized_phase_display = "Action & accountability" - elif phase_display == "WorkPlan": - normalized_phase_display = "Workplan" + # Compute normalized phase display from int value or existing string + normalized_phase_display = _phase_display_from_int(getattr(ov, "phase", None), getattr(ov, "phase_display", None)) # Attach components from latest assessment tied to the overview components = [] @@ -837,10 +773,22 @@ def get(self, request): "region_id": getattr(getattr(ov.country, "region", None), "id", None), "region_name": getattr(getattr(ov.country, "region", None), "label", None), "latitude": ( - ov.country.centroid.y if getattr(ov.country, "centroid", None) else getattr(ov.country, "latitude", None) + round(ov.country.centroid.y, 5) + if getattr(ov.country, "centroid", None) + else ( + round(getattr(ov.country, "latitude", None), 5) + if getattr(ov.country, "latitude", None) is not None + else None + ) ), "longitude": ( - ov.country.centroid.x if getattr(ov.country, "centroid", None) else getattr(ov.country, "longitude", None) + round(ov.country.centroid.x, 5) + if getattr(ov.country, "centroid", None) + else ( + round(getattr(ov.country, "longitude", None), 5) + if getattr(ov.country, "longitude", None) is not None + else None + ) ), "updated_at": getattr(ov, "updated_at", None), "prioritized_components": prioritized_components, @@ -866,101 +814,205 @@ def get(self, request): queryset=AreaResponse.objects.prefetch_related( Prefetch( "component_response", - queryset=FormComponentResponse.objects.prefetch_related("question_responses"), + queryset=FormComponentResponse.objects.prefetch_related("question_responses").select_related( + "component", + "component__area", + "rating", + ), ) ), ) ) results = [] for a in assessments: - components = [] + # Collect area entries with sort keys + area_entries = [] for ar in a.area_responses.all(): + component_entries = [] for cr in ar.component_response.all(): - cd = getattr(cr, "component_details", None) - rd = getattr(cr, "rating_details", None) - # When accessed via serializer, details exist; otherwise construct minimally - components.append( + comp = getattr(cr, "component", None) + area = getattr(comp, "area", None) if comp else None + rating = getattr(cr, "rating", None) + + rating_details = ( + { + "id": getattr(rating, "id", None), + "value": getattr(rating, "value", None), + "title": getattr(rating, "title", None), + } + if rating is not None + else None + ) + + component_details = ( { - "component_id": getattr(cr, "component_id", None), + "id": getattr(comp, "id", None), + "component_num": getattr(comp, "component_num", None), + "area": getattr(area, "id", None), + "title": getattr(comp, "title", None) + or getattr(comp, "description_en", None) + or getattr(comp, "description", None), + "description": getattr(comp, "description", None) or getattr(comp, "description_en", None), + } + if comp is not None + else None + ) + + component_entries.append( + { + "id": getattr(cr, "id", None), + "component": getattr(comp, "id", None), + "rating": getattr(rating, "id", None), + "rating_details": rating_details, + "component_details": component_details, + "urban_considerations": getattr(cr, "urban_considerations", None), + "epi_considerations": getattr(cr, "epi_considerations", None), + "climate_environmental_considerations": getattr(cr, "climate_environmental_considerations", None), + "migration_considerations": getattr(cr, "migration_considerations", None), "urban_considerations_simplified": _contains_affirmative(getattr(cr, "urban_considerations", "")), "epi_considerations_simplified": _contains_affirmative(getattr(cr, "epi_considerations", "")), - "migration_considerations_simplified": _contains_affirmative( - getattr(cr, "migration_considerations", "") - ), "climate_environmental_considerations_simplified": _contains_affirmative( getattr(cr, "climate_environmental_considerations", "") ), - "component_name": (cd.title if cd else getattr(cr.component, "title", None)), - "component_num": (cd.component_num if cd else getattr(cr.component, "component_num", None)), - "area_id": (cd.area if cd else getattr(cr.component.area, "id", None)), - "area_name": ( - AREA_NAMES.get(cd.area) - if (cd and isinstance(cd.area, int)) - else getattr(getattr(cr.component, "area", None), "name", None) + "migration_considerations_simplified": _contains_affirmative( + getattr(cr, "migration_considerations", "") ), - "rating_value": (rd.value if rd else getattr(getattr(cr, "rating", None), "value", None)), - "rating_title": (rd.title if rd else getattr(getattr(cr, "rating", None), "title", None)), + "notes": getattr(cr, "notes", None), + "_component_num": getattr(comp, "component_num", 0), } ) + # Sort components by component_num + component_entries.sort(key=lambda x: x.get("_component_num") or 0) + # Remove sort helper keys + for ce in component_entries: + ce.pop("_component_num", None) + + area_entries.append( + { + "id": getattr(ar, "id", None), + "component_responses": component_entries, + "_area_num": getattr(getattr(comp, "area", None), "area_num", 0) if comp else 0, + } + ) + # Sort areas by area_num + area_entries.sort(key=lambda x: x.get("_area_num") or 0) + for ae in area_entries: + ae.pop("_area_num", None) + results.append( { - "assessment_id": a.id, - "country_id": getattr(a.overview, "country_id", None), - "country_name": getattr(getattr(a.overview, "country", None), "name", None), - "components": components, + "id": getattr(a, "id", None), + "area_responses": area_entries, } ) + return Response({"results": results}) class PerDashboardDataView(views.APIView): """Public consolidated PER dashboard data. - Groups latest per country overview and attaches lightweight assessment entries. + Aggregates by PER components (not countries) and attaches assessments. """ def get(self, request): - latest_overviews = ( - Overview.objects.order_by("country_id", "-assessment_number", "-date_of_assessment") - .distinct("country_id") - .select_related("country") - ) - # Map assessments by country for quick attach - assessments_by_country = {} - for a in PerAssessment.objects.select_related("overview"): - cid = getattr(a.overview, "country_id", None) - if cid is None: - continue - assessments_by_country.setdefault(cid, []).append( - { - "assessment_id": a.id, - "assessment_number": getattr(a.overview, "assessment_number", None), - "date_of_assessment": getattr(a.overview, "date_of_assessment", None), - } - ) - items = [] - for ov in latest_overviews: - phase_display = ov.get_phase_display() if hasattr(ov, "get_phase_display") else getattr(ov, "phase_display", None) - if phase_display == "Action And Accountability": - phase_display = "Action & accountability" - elif phase_display == "WorkPlan": - phase_display = "Workplan" - items.append( - { - "country_id": ov.country_id, - "country_name": ov.country.name if ov.country else None, - "country_iso3": getattr(ov.country, "iso3", None), - "phase": phase_display, - "assessment_number": ov.assessment_number, - "date_of_assessment": ov.date_of_assessment, - "countryAssessments": sorted( - assessments_by_country.get(ov.country_id, []), - key=lambda x: (x["date_of_assessment"] or datetime.min), - reverse=True, - ), - } + # Build aggregation by component across all assessments + component_map = {} + country_assessments: dict[str, list] = {} + # Prefetch for performance + assessments = PerAssessment.objects.select_related("overview", "overview__country").prefetch_related( + Prefetch( + "area_responses", + queryset=AreaResponse.objects.prefetch_related( + Prefetch( + "component_response", + queryset=FormComponentResponse.objects.select_related("component", "component__area", "rating"), + ) + ), ) - return Response({"results": items}) + ) + + for a in assessments: + assessment_entry = { + "assessment_id": getattr(a, "id", None), + "assessment_number": getattr(a.overview, "assessment_number", None), + "date_of_assessment": getattr(a.overview, "date_of_assessment", None), + "country_id": getattr(a.overview, "country_id", None), + "country_name": getattr(getattr(a.overview, "country", None), "name", None), + "country_iso3": getattr(getattr(a.overview, "country", None), "iso3", None), + } + # Also prepare detailed assessment for countryAssessments with ratings + ca_components = [] + + for ar in a.area_responses.all(): + for cr in ar.component_response.all(): + comp = getattr(cr, "component", None) + if comp is None: + continue + area = getattr(comp, "area", None) + comp_id = getattr(comp, "id", None) + if comp_id is None: + continue + # Component key aggregation + if comp_id not in component_map: + component_map[comp_id] = { + "component_id": comp_id, + "component_num": getattr(comp, "component_num", None), + "component_name": getattr(comp, "title", None) + or getattr(comp, "description_en", None) + or getattr(comp, "description", None), + "area_id": getattr(area, "id", None), + "area_name": ( + AREA_NAMES.get(int(getattr(area, "area_num", 0))) + if isinstance(getattr(area, "area_num", None), int) + else getattr(area, "name", None) + ), + "assessments": [], + } + + component_map[comp_id]["assessments"].append(assessment_entry) + + # Build component entry with rating for countryAssessments + rating = getattr(cr, "rating", None) + ca_components.append( + { + "component_id": comp_id, + "component_name": getattr(comp, "title", None) + or getattr(comp, "description_en", None) + or getattr(comp, "description", None), + "component_num": getattr(comp, "component_num", None), + "area_id": getattr(area, "id", None), + "area_name": ( + AREA_NAMES.get(int(getattr(area, "area_num", 0))) + if isinstance(getattr(area, "area_num", None), int) + else getattr(area, "name", None) + ), + "rating_value": getattr(rating, "value", None), + "rating_title": getattr(rating, "title", None) or "", + } + ) + + # Append to countryAssessments mapping + country_name = assessment_entry["country_name"] + if country_name: + phase_display = _phase_display_from_int( + getattr(a.overview, "phase", None), getattr(a.overview, "phase_display", None) + ) + country_assessments.setdefault(country_name, []).append( + { + "assessment_number": assessment_entry["assessment_number"], + "date": assessment_entry["date_of_assessment"], + "components": ca_components, + "phase": getattr(a.overview, "phase", None), + "phase_display": phase_display, + } + ) + + # Convert to list + items = list(component_map.values()) + # Optional: sort by area then component_num for stable output + items.sort(key=lambda x: ((x["area_id"] or 0), (x["component_num"] or 0))) + return Response({"assessments": items, "countryAssessments": country_assessments}) class PerFileViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet):