Skip to content

Commit 264d1f9

Browse files
feat(Hackathon): Platform Hub (#6692)
Co-authored-by: Talisson Costa <talisson.odcosta@gmail.com>
1 parent 88c4294 commit 264d1f9

35 files changed

Lines changed: 4675 additions & 35 deletions

api/api/urls/v1.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@
9090
),
9191
name="schema-swagger-ui",
9292
),
93+
# Platform Hub dashboard
94+
re_path(
95+
r"^admin/dashboard/",
96+
include("platform_hub.urls", namespace="platform-hub"),
97+
),
9398
# Test webhook url
9499
re_path(r"^webhooks/", include("webhooks.urls", namespace="webhooks")),
95100
path("", include("projects.code_references.urls", namespace="code_references")),

api/app/settings/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@
130130
"webhooks",
131131
"metrics",
132132
"onboarding",
133+
"platform_hub",
133134
# 2FA
134135
"custom_auth.mfa.trench",
135136
# health check plugins

api/app_analytics/influxdb_wrapper.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,58 @@ def get_current_api_usage(
459459
return 0
460460

461461

462+
def get_platform_usage_trends(
463+
date_start: datetime,
464+
date_stop: datetime,
465+
organisation_ids: list[int],
466+
) -> dict[str, dict[str, int]]:
467+
"""
468+
Query InfluxDB for platform-wide usage trends grouped by day and resource.
469+
470+
Returns a dict of {date_str: {resource_name: count}}.
471+
"""
472+
if not organisation_ids:
473+
return {}
474+
475+
org_id_set = ", ".join(f'"{oid}"' for oid in organisation_ids)
476+
477+
bucket = get_range_bucket_mappings(date_start)
478+
results = InfluxDBWrapper.influx_query_manager(
479+
date_start=date_start,
480+
date_stop=date_stop,
481+
bucket=bucket,
482+
filters=build_filter_string(
483+
[
484+
'r._measurement == "api_call"',
485+
'r["_field"] == "request_count"',
486+
f"contains(value: r.organisation_id, set: [{org_id_set}])",
487+
]
488+
),
489+
drop_columns=(
490+
"organisation",
491+
"organisation_id",
492+
"project",
493+
"project_id",
494+
"environment",
495+
"environment_id",
496+
"host",
497+
),
498+
extra=(
499+
'|> group(columns: ["resource"])'
500+
' |> aggregateWindow(every: 24h, fn: sum, timeSrc: "_start")'
501+
),
502+
)
503+
504+
daily: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
505+
for table in results:
506+
for record in table.records:
507+
date_str = record.values["_time"].strftime("%Y-%m-%d")
508+
resource_name = record.values.get("resource", "unknown")
509+
daily[date_str][resource_name] += record.get_value() or 0
510+
511+
return daily
512+
513+
462514
def build_filter_string(filter_expressions: typing.List[str]) -> str:
463515
return "|> ".join(
464516
["", *[f"filter(fn: (r) => {exp})" for exp in filter_expressions]]

api/conftest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1303,3 +1303,27 @@ def _is_feature_enabled(feature_name: str) -> bool:
13031303
def clear_content_type_cache() -> typing.Generator[None, None, None]:
13041304
yield
13051305
ContentType.objects.clear_cache()
1306+
1307+
1308+
@pytest.fixture
1309+
def use_analytics_db(request: pytest.FixtureRequest, settings: SettingsWrapper) -> None:
1310+
"""
1311+
Skip tests if no analytics database is configured,
1312+
and make sure the django_db fixture uses both default and analytics databases.
1313+
This is useful to avoid running tests that require a specific database setup.
1314+
"""
1315+
if "analytics" not in settings.DATABASES: # pragma: no cover
1316+
pytest.skip("No analytics database configured, skipping")
1317+
return
1318+
request.applymarker(pytest.mark.django_db(databases=["default", "analytics"]))
1319+
request.getfixturevalue("db")
1320+
1321+
1322+
@pytest.fixture(autouse=True)
1323+
def use_analytics_db_marked(request: pytest.FixtureRequest) -> None:
1324+
"""
1325+
Automatically skip tests that are marked with 'use_analytics_db'.
1326+
This allows for selective skipping of tests based on the database configuration.
1327+
"""
1328+
if request.node.get_closest_marker("use_analytics_db"):
1329+
request.getfixturevalue("use_analytics_db")

api/platform_hub/__init__.py

Whitespace-only changes.

api/platform_hub/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.apps import AppConfig
2+
3+
4+
class PlatformHubConfig(AppConfig):
5+
name = "platform_hub"

api/platform_hub/constants.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from typing import Any
2+
3+
# Each entry: (ModelClass, org_lookup_path, scope)
4+
# Using Any for model class to avoid mypy issues with Django model managers.
5+
IntegrationEntry = tuple[Any, str, str]
6+
7+
8+
def get_integration_registry() -> dict[str, IntegrationEntry]:
9+
"""Return the registry of integration models, lazily imported to avoid circular imports."""
10+
from integrations.amplitude.models import AmplitudeConfiguration
11+
from integrations.datadog.models import DataDogConfiguration
12+
from integrations.dynatrace.models import DynatraceConfiguration
13+
from integrations.github.models import GithubConfiguration
14+
from integrations.grafana.models import (
15+
GrafanaOrganisationConfiguration,
16+
GrafanaProjectConfiguration,
17+
)
18+
from integrations.heap.models import HeapConfiguration
19+
from integrations.mixpanel.models import MixpanelConfiguration
20+
from integrations.new_relic.models import NewRelicConfiguration
21+
from integrations.rudderstack.models import RudderstackConfiguration
22+
from integrations.segment.models import SegmentConfiguration
23+
from integrations.sentry.models import SentryChangeTrackingConfiguration
24+
from integrations.slack.models import SlackConfiguration
25+
from integrations.webhook.models import WebhookConfiguration
26+
27+
return {
28+
"amplitude": (
29+
AmplitudeConfiguration,
30+
"environment__project__organisation_id",
31+
"environment",
32+
),
33+
"dynatrace": (
34+
DynatraceConfiguration,
35+
"environment__project__organisation_id",
36+
"environment",
37+
),
38+
"heap": (
39+
HeapConfiguration,
40+
"environment__project__organisation_id",
41+
"environment",
42+
),
43+
"mixpanel": (
44+
MixpanelConfiguration,
45+
"environment__project__organisation_id",
46+
"environment",
47+
),
48+
"rudderstack": (
49+
RudderstackConfiguration,
50+
"environment__project__organisation_id",
51+
"environment",
52+
),
53+
"segment": (
54+
SegmentConfiguration,
55+
"environment__project__organisation_id",
56+
"environment",
57+
),
58+
"sentry": (
59+
SentryChangeTrackingConfiguration,
60+
"environment__project__organisation_id",
61+
"environment",
62+
),
63+
"webhook": (
64+
WebhookConfiguration,
65+
"environment__project__organisation_id",
66+
"environment",
67+
),
68+
"datadog": (
69+
DataDogConfiguration,
70+
"project__organisation_id",
71+
"project",
72+
),
73+
"new-relic": (
74+
NewRelicConfiguration,
75+
"project__organisation_id",
76+
"project",
77+
),
78+
"slack": (
79+
SlackConfiguration,
80+
"project__organisation_id",
81+
"project",
82+
),
83+
"grafana-project": (
84+
GrafanaProjectConfiguration,
85+
"project__organisation_id",
86+
"project",
87+
),
88+
"github": (
89+
GithubConfiguration,
90+
"organisation_id",
91+
"organisation",
92+
),
93+
"grafana": (
94+
GrafanaOrganisationConfiguration,
95+
"organisation_id",
96+
"organisation",
97+
),
98+
}

api/platform_hub/mappers.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from platform_hub.types import ReleasePipelineStageStatsData
6+
7+
8+
def map_release_pipeline_stage_to_stats_data(
9+
stage: Any,
10+
) -> ReleasePipelineStageStatsData:
11+
"""Map a PipelineStage instance to a ReleasePipelineStageStatsData dict."""
12+
features_in_stage: int = stage.get_in_stage_feature_versions_qs().count()
13+
features_completed: int = stage.get_completed_feature_versions_qs().count()
14+
15+
return ReleasePipelineStageStatsData(
16+
stage_name=stage.name,
17+
environment_name=stage.environment.name,
18+
order=stage.order,
19+
features_in_stage=features_in_stage,
20+
features_completed=features_completed,
21+
action_description=_build_action_description(stage),
22+
trigger_description=_build_trigger_description(stage),
23+
)
24+
25+
26+
def _build_action_description(stage: Any) -> str:
27+
"""Build a human-readable description of the stage's actions."""
28+
actions = stage.actions.all()
29+
if not actions:
30+
return ""
31+
descriptions: list[str] = []
32+
for action in actions:
33+
descriptions.append(action.get_action_type_display())
34+
return ", ".join(descriptions)
35+
36+
37+
def _build_trigger_description(stage: Any) -> str:
38+
"""Build a human-readable description of the stage's trigger."""
39+
try:
40+
trigger = stage.trigger
41+
except stage.__class__.trigger.RelatedObjectDoesNotExist:
42+
return ""
43+
44+
description: str = trigger.get_trigger_type_display()
45+
if trigger.trigger_body and "wait_for" in trigger.trigger_body:
46+
description += f" ({trigger.trigger_body['wait_for']})"
47+
return description

api/platform_hub/serializers.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from rest_framework import serializers
2+
3+
4+
class DaysQuerySerializer(serializers.Serializer): # type: ignore[type-arg]
5+
days = serializers.ChoiceField(
6+
choices=[30, 60, 90],
7+
default=30,
8+
)
9+
10+
def validate_days(self, value: str | int) -> int:
11+
return int(value)
12+
13+
14+
class SummarySerializer(serializers.Serializer): # type: ignore[type-arg]
15+
total_organisations = serializers.IntegerField()
16+
total_flags = serializers.IntegerField()
17+
total_users = serializers.IntegerField()
18+
total_api_calls_30d = serializers.IntegerField()
19+
active_organisations = serializers.IntegerField()
20+
total_projects = serializers.IntegerField()
21+
total_environments = serializers.IntegerField()
22+
total_integrations = serializers.IntegerField()
23+
active_users = serializers.IntegerField()
24+
25+
26+
class EnvironmentMetricsSerializer(serializers.Serializer): # type: ignore[type-arg]
27+
id = serializers.IntegerField()
28+
name = serializers.CharField()
29+
api_calls_30d = serializers.IntegerField()
30+
flag_evaluations_30d = serializers.IntegerField()
31+
32+
33+
class ProjectMetricsSerializer(serializers.Serializer): # type: ignore[type-arg]
34+
id = serializers.IntegerField()
35+
name = serializers.CharField()
36+
api_calls_30d = serializers.IntegerField()
37+
flag_evaluations_30d = serializers.IntegerField()
38+
flags = serializers.IntegerField()
39+
environments = EnvironmentMetricsSerializer(many=True)
40+
41+
42+
class OrganisationMetricsSerializer(serializers.Serializer): # type: ignore[type-arg]
43+
id = serializers.IntegerField()
44+
name = serializers.CharField()
45+
created_date = serializers.CharField()
46+
total_flags = serializers.IntegerField()
47+
active_flags = serializers.IntegerField()
48+
stale_flags = serializers.IntegerField()
49+
total_users = serializers.IntegerField()
50+
active_users_30d = serializers.IntegerField()
51+
admin_users = serializers.IntegerField()
52+
api_calls_30d = serializers.IntegerField()
53+
api_calls_60d = serializers.IntegerField()
54+
api_calls_90d = serializers.IntegerField()
55+
api_calls_allowed = serializers.IntegerField()
56+
flag_evaluations_30d = serializers.IntegerField()
57+
identity_requests_30d = serializers.IntegerField()
58+
overage_30d = serializers.IntegerField()
59+
overage_60d = serializers.IntegerField()
60+
overage_90d = serializers.IntegerField()
61+
project_count = serializers.IntegerField()
62+
environment_count = serializers.IntegerField()
63+
integration_count = serializers.IntegerField()
64+
projects = ProjectMetricsSerializer(many=True)
65+
66+
67+
class UsageTrendSerializer(serializers.Serializer): # type: ignore[type-arg]
68+
date = serializers.CharField()
69+
api_calls = serializers.IntegerField()
70+
flag_evaluations = serializers.IntegerField()
71+
identity_requests = serializers.IntegerField()
72+
73+
74+
class StaleFlagsPerProjectSerializer(serializers.Serializer): # type: ignore[type-arg]
75+
organisation_id = serializers.IntegerField()
76+
organisation_name = serializers.CharField()
77+
project_id = serializers.IntegerField()
78+
project_name = serializers.CharField()
79+
stale_flags = serializers.IntegerField()
80+
total_flags = serializers.IntegerField()
81+
82+
83+
class IntegrationBreakdownSerializer(serializers.Serializer): # type: ignore[type-arg]
84+
organisation_id = serializers.IntegerField()
85+
organisation_name = serializers.CharField()
86+
integration_type = serializers.CharField()
87+
scope = serializers.CharField()
88+
count = serializers.IntegerField()
89+
90+
91+
class ReleasePipelineStageStatsSerializer(
92+
serializers.Serializer, # type: ignore[type-arg]
93+
):
94+
stage_name = serializers.CharField()
95+
environment_name = serializers.CharField()
96+
order = serializers.IntegerField()
97+
features_in_stage = serializers.IntegerField()
98+
features_completed = serializers.IntegerField()
99+
action_description = serializers.CharField()
100+
trigger_description = serializers.CharField()
101+
102+
103+
class ReleasePipelineOverviewSerializer(serializers.Serializer): # type: ignore[type-arg]
104+
organisation_id = serializers.IntegerField()
105+
organisation_name = serializers.CharField()
106+
project_id = serializers.IntegerField()
107+
project_name = serializers.CharField()
108+
pipeline_id = serializers.IntegerField()
109+
pipeline_name = serializers.CharField()
110+
is_published = serializers.BooleanField()
111+
total_features = serializers.IntegerField()
112+
completed_features = serializers.IntegerField()
113+
stages = ReleasePipelineStageStatsSerializer(many=True)

0 commit comments

Comments
 (0)