diff --git a/django-backend/soroscan/ingest/apps.py b/django-backend/soroscan/ingest/apps.py index d3fc886e..afdac45a 100644 --- a/django-backend/soroscan/ingest/apps.py +++ b/django-backend/soroscan/ingest/apps.py @@ -1,3 +1,5 @@ +import time + from django.apps import AppConfig @@ -6,5 +8,8 @@ class IngestConfig(AppConfig): name = "soroscan.ingest" verbose_name = "SoroScan Ingest" + start_time: float | None = None + def ready(self): import soroscan.ingest.signals # noqa: F401 — registers signal handlers + IngestConfig.start_time = time.monotonic() diff --git a/django-backend/soroscan/ingest/migrations/0041_contractevent_contract_address.py b/django-backend/soroscan/ingest/migrations/0041_contractevent_contract_address.py new file mode 100644 index 00000000..4d50b261 --- /dev/null +++ b/django-backend/soroscan/ingest/migrations/0041_contractevent_contract_address.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + + +def populate_contract_address(apps, schema_editor): + ContractEvent = apps.get_model("ingest", "ContractEvent") + for event in ContractEvent.objects.select_related("contract").iterator(): + event.contract_address = event.contract.contract_id + event.save(update_fields=["contract_address"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("ingest", "0040_alter_trackedcontract_contract_id"), + ] + + operations = [ + migrations.AddField( + model_name="contractevent", + name="contract_address", + field=models.CharField( + db_index=True, + default="", + help_text="Denormalized contract address for fast address-based queries", + max_length=56, + ), + ), + migrations.RunPython( + populate_contract_address, + migrations.RunPython.noop, + ), + ] diff --git a/django-backend/soroscan/ingest/models.py b/django-backend/soroscan/ingest/models.py index 85352e05..1d78b7ee 100644 --- a/django-backend/soroscan/ingest/models.py +++ b/django-backend/soroscan/ingest/models.py @@ -655,6 +655,12 @@ class ContractEvent(models.Model): db_index=True, help_text="Result of event signature verification", ) + contract_address = models.CharField( + max_length=56, + db_index=True, + default="", + help_text="Denormalized contract address for fast address-based queries", + ) class Meta: ordering = ["-timestamp"] @@ -678,7 +684,8 @@ def __str__(self): return f"{self.event_type}@{self.ledger} ({self.contract.name})" def save(self, *args, **kwargs): - # Auto-compute payload hash if not set + if not self.contract_address and self.contract_id: + self.contract_address = self.contract.contract_id if not self.payload_hash and self.payload: payload_bytes = str(self.payload).encode("utf-8") self.payload_hash = hashlib.sha256(payload_bytes).hexdigest() @@ -794,6 +801,12 @@ class WebhookSubscription(models.Model): class Meta: ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["target_url", "contract"], + name="unique_webhook_url_contract", + ) + ] def __str__(self): return f"Webhook -> {self.target_url} ({self.contract.name})" diff --git a/django-backend/soroscan/ingest/tests/test_health.py b/django-backend/soroscan/ingest/tests/test_health.py index 719e1a8e..42e9cddc 100644 --- a/django-backend/soroscan/ingest/tests/test_health.py +++ b/django-backend/soroscan/ingest/tests/test_health.py @@ -1,3 +1,5 @@ +import time + import pytest from django.conf import settings from django.core.cache import cache @@ -6,7 +8,6 @@ from rest_framework.test import APIClient - @pytest.fixture def api_client(): return APIClient() @@ -61,4 +62,48 @@ def mocked_get(*args, **kwargs): assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE assert response.data["status"] == "not_ready" assert any("redis" in e for e in response.data["errors"]) - assert response["X-SoroScan-Version"] == settings.SOFTWARE_VERSION \ No newline at end of file + assert response["X-SoroScan-Version"] == settings.SOFTWARE_VERSION + + +@pytest.mark.django_db +class TestIngestHealthCheck: + def test_health_check_returns_healthy(self, api_client): + url = reverse("health-check") + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["status"] == "healthy" + assert response.data["service"] == "soroscan" + + def test_health_check_includes_uptime_seconds(self, api_client): + from soroscan.ingest.apps import IngestConfig + + IngestConfig.start_time = time.monotonic() - 10 + + url = reverse("health-check") + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "uptime_seconds" in response.data + assert response.data["uptime_seconds"] >= 10 + + def test_health_check_uptime_is_positive(self, api_client): + from soroscan.ingest.apps import IngestConfig + + IngestConfig.start_time = time.monotonic() + + url = reverse("health-check") + response = api_client.get(url) + + assert response.data["uptime_seconds"] >= 0 + + def test_health_check_includes_human_readable_uptime(self, api_client): + from soroscan.ingest.apps import IngestConfig + + IngestConfig.start_time = time.monotonic() - 3661 + + url = reverse("health-check") + response = api_client.get(url) + + assert "uptime" in response.data + assert response.data["uptime"] == "0d 01:01:01" \ No newline at end of file diff --git a/django-backend/soroscan/ingest/views.py b/django-backend/soroscan/ingest/views.py index 02b1bcb9..77149edd 100644 --- a/django-backend/soroscan/ingest/views.py +++ b/django-backend/soroscan/ingest/views.py @@ -128,6 +128,16 @@ def retrieve(self, request, *args, **kwargs): response.data.setdefault("warnings", []) return response + def create(self, request, *args, **kwargs): + dry_run = request.query_params.get("dry_run", "").lower() == "true" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + if dry_run: + return Response({"detail": "Valid"}, status=status.HTTP_200_OK) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + def perform_create(self, serializer): serializer.save(owner=self.request.user) @@ -850,6 +860,8 @@ def record_event_view(request): fields={ "status": serializers.CharField(), "service": serializers.CharField(), + "uptime_seconds": serializers.IntegerField(), + "uptime": serializers.CharField(), }, ) ) @@ -857,7 +869,24 @@ def record_event_view(request): @permission_classes([AllowAny]) def health_check(request): """Health check endpoint.""" - return Response({"status": "healthy", "service": "soroscan"}) + from soroscan.ingest.apps import IngestConfig + + uptime_seconds = 0 + if IngestConfig.start_time is not None: + uptime_seconds = int(time.monotonic() - IngestConfig.start_time) + + d = uptime_seconds // 86400 + h = (uptime_seconds % 86400) // 3600 + m = (uptime_seconds % 3600) // 60 + s = uptime_seconds % 60 + uptime_human = f"{d}d {h:02d}:{m:02d}:{s:02d}" + + return Response({ + "status": "healthy", + "service": "soroscan", + "uptime_seconds": uptime_seconds, + "uptime": uptime_human, + }) @extend_schema(