From 49a324bdc701b6069ef96e39e865d5536bdc1a9d Mon Sep 17 00:00:00 2001 From: Sule Abdulhakeem Date: Fri, 29 May 2026 18:33:44 +0100 Subject: [PATCH] feat: add event type statistics endpoint --- .../tests/test_event_type_statistics.py | 129 ++++++++++++++++++ django-backend/soroscan/ingest/urls.py | 6 +- django-backend/soroscan/ingest/views.py | 70 ++++++++++ 3 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 django-backend/soroscan/ingest/tests/test_event_type_statistics.py diff --git a/django-backend/soroscan/ingest/tests/test_event_type_statistics.py b/django-backend/soroscan/ingest/tests/test_event_type_statistics.py new file mode 100644 index 00000000..c19d9bae --- /dev/null +++ b/django-backend/soroscan/ingest/tests/test_event_type_statistics.py @@ -0,0 +1,129 @@ +import pytest +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from soroscan.ingest.models import ContractEvent, TrackedContract + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture(autouse=True) +def clear_cache(): + cache.clear() + + +@pytest.fixture +def user(db): + User = get_user_model() + return User.objects.create_user(username="stats-user", password="password") + + +@pytest.fixture +def contract(user): + return TrackedContract.objects.create( + contract_id="C" + "A" * 55, + name="Stats Contract", + owner=user, + ) + + +@pytest.fixture +def other_contract(user): + return TrackedContract.objects.create( + contract_id="C" + "B" * 55, + name="Other Contract", + owner=user, + ) + + +def create_event(contract, event_type, ledger, event_index): + return ContractEvent.objects.create( + contract=contract, + event_type=event_type, + payload={"event_type": event_type}, + payload_hash=f"hash-{contract.id}-{ledger}-{event_index}", + ledger=ledger, + event_index=event_index, + timestamp=timezone.now(), + tx_hash=f"tx-{contract.id}-{ledger}-{event_index}", + ) + + +@pytest.mark.django_db +class TestEventTypeStatisticsEndpoint: + def test_endpoint_returns_event_type_distribution_for_contract( + self, + api_client, + contract, + ): + create_event(contract, "transfer", 100, 0) + create_event(contract, "transfer", 101, 0) + create_event(contract, "swap", 102, 0) + + url = reverse("event-type-statistics") + response = api_client.get(url, {"contract_id": contract.contract_id}) + + assert response.status_code == status.HTTP_200_OK + assert response.data["contract_id"] == contract.contract_id + assert response.data["total_events"] == 3 + + counts = { + item["event_type"]: item["count"] + for item in response.data["event_types"] + } + + assert counts["transfer"] == 2 + assert counts["swap"] == 1 + + def test_endpoint_filters_by_contract( + self, + api_client, + contract, + other_contract, + ): + create_event(contract, "transfer", 100, 0) + create_event(contract, "transfer", 101, 0) + create_event(other_contract, "mint", 200, 0) + + url = reverse("event-type-statistics") + response = api_client.get(url, {"contract_id": contract.contract_id}) + + assert response.status_code == status.HTTP_200_OK + assert response.data["total_events"] == 2 + + returned_contract_ids = { + item["contract_id"] + for item in response.data["event_types"] + } + + assert returned_contract_ids == {contract.contract_id} + + def test_endpoint_returns_global_distribution_without_contract_filter( + self, + api_client, + contract, + other_contract, + ): + create_event(contract, "transfer", 100, 0) + create_event(other_contract, "mint", 200, 0) + + url = reverse("event-type-statistics") + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["contract_id"] is None + assert response.data["total_events"] == 2 + assert len(response.data["event_types"]) == 2 + + def test_endpoint_returns_404_for_unknown_contract(self, api_client): + url = reverse("event-type-statistics") + response = api_client.get(url, {"contract_id": "C" + "Z" * 55}) + + assert response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/django-backend/soroscan/ingest/urls.py b/django-backend/soroscan/ingest/urls.py index f1896457..80e65fe9 100644 --- a/django-backend/soroscan/ingest/urls.py +++ b/django-backend/soroscan/ingest/urls.py @@ -10,19 +10,20 @@ ContractInvocationViewSet, TeamViewSet, TrackedContractViewSet, + WebhookSubscriptionViewSet, admin_ingest_errors_view, audit_trail_view, compliance_export_view, contract_event_explorer_view, contract_event_types_view, contract_identity_view, - organization_cost_breakdown_view, - WebhookSubscriptionViewSet, contract_timeline_view, deletion_requests_view, deployment_timeline_view, + event_type_statistics_view, health_check, networks_view, + organization_cost_breakdown_view, record_event_view, restore_archived_events, transaction_events_view, @@ -54,6 +55,7 @@ deployment_timeline_view, name="contract-deployments", ), + path("events/type-stats/", event_type_statistics_view, name="event-type-statistics"), path("transactions//", transaction_events_view, name="transaction-events"), path( "contracts//vulnerability-impact/", diff --git a/django-backend/soroscan/ingest/views.py b/django-backend/soroscan/ingest/views.py index 8e956d8b..590dda5d 100644 --- a/django-backend/soroscan/ingest/views.py +++ b/django-backend/soroscan/ingest/views.py @@ -1087,6 +1087,76 @@ def _build(): return Response(result) + +@extend_schema( + parameters=[ + inline_serializer( + name="EventTypeStatisticsParams", + fields={ + "contract_id": serializers.CharField(required=False), + }, + ) + ], + responses=inline_serializer( + name="EventTypeStatisticsResponse", + fields={ + "contract_id": serializers.CharField(allow_null=True), + "total_events": serializers.IntegerField(), + "event_types": serializers.JSONField(), + }, + ), +) +@api_view(["GET"]) +@permission_classes([AllowAny]) +def event_type_statistics_view(request): + """Return event type distribution, optionally filtered by contract_id.""" + contract_id = request.query_params.get("contract_id", "").strip() + + events = ContractEvent.objects.select_related("contract").all() + contract = None + + if contract_id: + contract = get_cached_contract(contract_id) + if not contract: + from django.http import Http404 + raise Http404 + events = events.filter(contract=contract) + + cache_key = stable_cache_key( + "event_type_statistics", + {"contract_id": contract_id or "all"}, + ) + + def _build(): + stats = list( + events.values("contract__contract_id", "event_type") + .annotate( + count=Count("id"), + first_seen=Min("timestamp"), + last_seen=Max("timestamp"), + ) + .order_by("contract__contract_id", "-count", "event_type") + ) + + return { + "contract_id": contract.contract_id if contract else None, + "total_events": events.count(), + "event_types": [ + { + "contract_id": row["contract__contract_id"], + "event_type": row["event_type"], + "count": row["count"], + "first_seen": row["first_seen"], + "last_seen": row["last_seen"], + } + for row in stats + ], + } + + return Response(get_or_set_json(cache_key, 60, _build)) + + + @extend_schema( parameters=[ inline_serializer(