From ce4b584507229e64602b11e15e193c505738f159 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 12 Feb 2026 11:11:22 +0000 Subject: [PATCH] fix(Hackathon): Platform Hub: inverted stale counts --- api/platform_hub/services.py | 54 +++++++++---------- api/tests/unit/platform_hub/test_services.py | 31 +++++------ .../components/StaleFlagsTable.tsx | 18 ++----- 3 files changed, 43 insertions(+), 60 deletions(-) diff --git a/api/platform_hub/services.py b/api/platform_hub/services.py index b9004c7c1a8c..53d58639d3b3 100644 --- a/api/platform_hub/services.py +++ b/api/platform_hub/services.py @@ -30,6 +30,7 @@ UsageTrendData, ) from projects.models import Project +from projects.tags.models import TagType from users.models import FFAdminUser logger = structlog.get_logger("platform_hub") @@ -355,29 +356,34 @@ def get_organisation_metrics( return results -def _get_stale_flag_counts_by_org( +def _get_stale_flag_counts_by_project( organisations: QuerySet[Organisation], ) -> dict[int, int]: - """Return a mapping of organisation_id -> stale flag count.""" - projects = Project.objects.filter( - organisation__in=organisations, - ).values("id", "organisation_id", "stale_flags_limit_days") + """Return a mapping of project_id -> stale flag count.""" + return dict( + Feature.objects.filter( + project__organisation__in=organisations, + tags__type=TagType.STALE, + ) + .values("project_id") + .annotate(count=Count("id", distinct=True)) + .values_list("project_id", "count") + ) - result: dict[int, int] = defaultdict(int) - for project in projects: - cutoff = timezone.now() - timedelta(days=project["stale_flags_limit_days"]) - stale_count = ( - Feature.objects.filter(project_id=project["id"]) - .exclude( - feature_states__updated_at__gte=cutoff, - ) - .distinct() - .count() +def _get_stale_flag_counts_by_org( + organisations: QuerySet[Organisation], +) -> dict[int, int]: + """Return a mapping of organisation_id -> stale flag count.""" + return dict( + Feature.objects.filter( + project__organisation__in=organisations, + tags__type=TagType.STALE, ) - result[project["organisation_id"]] += stale_count - - return result + .values("project__organisation_id") + .annotate(count=Count("id", distinct=True)) + .values_list("project__organisation_id", "count") + ) def _get_integration_counts_by_org( @@ -501,27 +507,21 @@ def get_stale_flags_per_project( .values_list("project_id", "count") ) + stale_counts_by_project = _get_stale_flag_counts_by_project(organisations) + results: list[StaleFlagsPerProjectData] = [] for project in projects: total_flags = flag_counts_by_project.get(project.id, 0) if total_flags == 0: continue - cutoff = timezone.now() - timedelta(days=project.stale_flags_limit_days) - stale_flags = ( - Feature.objects.filter(project=project) - .exclude(feature_states__updated_at__gte=cutoff) - .distinct() - .count() - ) - results.append( StaleFlagsPerProjectData( organisation_id=project.organisation_id, organisation_name=project.organisation.name, project_id=project.id, project_name=project.name, - stale_flags=stale_flags, + stale_flags=stale_counts_by_project.get(project.id, 0), total_flags=total_flags, ) ) diff --git a/api/tests/unit/platform_hub/test_services.py b/api/tests/unit/platform_hub/test_services.py index 7e112f1d7a2a..054da716fd07 100644 --- a/api/tests/unit/platform_hub/test_services.py +++ b/api/tests/unit/platform_hub/test_services.py @@ -7,13 +7,14 @@ from pytest_mock import MockerFixture from environments.models import Environment -from features.models import Feature, FeatureState +from features.models import Feature from organisations.models import ( Organisation, OrganisationSubscriptionInformationCache, ) from platform_hub import services from projects.models import Project +from projects.tags.models import Tag, TagType from users.models import FFAdminUser @@ -418,9 +419,7 @@ def test_get_organisation_metrics__query_count_stable_across_projects( result = services.get_organisation_metrics(orgs) # Then — query count should not grow per project. - # The stale flag count still runs one query per project, so we allow - # exactly +1 for the additional project's stale flag query. - assert len(ctx_two) <= baseline + 1 + assert len(ctx_two) <= baseline assert len(result) == 1 assert result[0]["project_count"] == 2 @@ -456,33 +455,29 @@ def test_get_stale_flags_per_project__query_count_stable_across_projects( with CaptureQueriesContext(connection) as ctx_two: result = services.get_stale_flags_per_project(orgs) - # Then — the flag_counts_by_project query is batched, so only +1 - # for the additional project's stale flag query. - assert len(ctx_two) <= baseline + 1 + # Then — query count should not grow per project. + assert len(ctx_two) <= baseline assert len(result) == 2 -def test_get_stale_flags_per_project__different_thresholds__counts_correctly( +def test_get_stale_flags_per_project__stale_tagged_feature__counts_correctly( platform_hub_organisation: Organisation, platform_hub_project: Project, platform_hub_environment: Environment, platform_hub_admin_user: FFAdminUser, ) -> None: - # Given — create a feature with a feature state updated long ago + # Given — create a feature tagged as stale feature = Feature.objects.create( name="stale_feature", project=platform_hub_project, ) - fs = FeatureState.objects.get( - feature=feature, - environment=platform_hub_environment, + stale_tag = Tag.objects.create( + label="Stale", + project=platform_hub_project, + type=TagType.STALE, + is_system_tag=True, ) - # Make the feature state old - old_date = timezone.now() - timedelta(days=60) - FeatureState.objects.filter(id=fs.id).update(updated_at=old_date) - - platform_hub_project.stale_flags_limit_days = 30 - platform_hub_project.save() + feature.tags.add(stale_tag) orgs = Organisation.objects.filter(id=platform_hub_organisation.id) diff --git a/frontend/web/components/pages/admin-dashboard/components/StaleFlagsTable.tsx b/frontend/web/components/pages/admin-dashboard/components/StaleFlagsTable.tsx index 478cae2368b6..54494cdf63ef 100644 --- a/frontend/web/components/pages/admin-dashboard/components/StaleFlagsTable.tsx +++ b/frontend/web/components/pages/admin-dashboard/components/StaleFlagsTable.tsx @@ -1,4 +1,4 @@ -import { FC, useMemo } from 'react' +import { FC } from 'react' import { StaleFlagsPerProject } from 'common/types/responses' import { SortOrder } from 'common/types/requests' import PanelSearch from 'components/PanelSearch' @@ -8,18 +8,6 @@ interface StaleFlagsTableProps { } const StaleFlagsTable: FC = ({ data }) => { - // TODO: The backend returns non-stale (active) flag counts in the stale_flags - // field. This should be fixed in the backend (platform_hub/services.py) to - // return the actual stale count, and this inversion removed. - const items = useMemo( - () => - data.map((row) => ({ - ...row, - stale_flags: row.total_flags - row.stale_flags, - })), - [data], - ) - return ( = ({ data }) => { } id='stale-flags-table' - items={items} - paging={items.length > 10 ? { goToPage: 1, pageSize: 10 } : undefined} + items={data} + paging={data.length > 10 ? { goToPage: 1, pageSize: 10 } : undefined} renderRow={(row: StaleFlagsPerProject) => (