Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions django-backend/soroscan/ingest/apps.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import time

from django.apps import AppConfig


Expand All @@ -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()
Original file line number Diff line number Diff line change
@@ -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,
),
]
15 changes: 14 additions & 1 deletion django-backend/soroscan/ingest/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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()
Expand Down Expand Up @@ -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})"
Expand Down
49 changes: 47 additions & 2 deletions django-backend/soroscan/ingest/tests/test_health.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import time

import pytest
from django.conf import settings
from django.core.cache import cache
Expand All @@ -6,7 +8,6 @@
from rest_framework.test import APIClient



@pytest.fixture
def api_client():
return APIClient()
Expand Down Expand Up @@ -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
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"
31 changes: 30 additions & 1 deletion django-backend/soroscan/ingest/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -850,14 +860,33 @@ def record_event_view(request):
fields={
"status": serializers.CharField(),
"service": serializers.CharField(),
"uptime_seconds": serializers.IntegerField(),
"uptime": serializers.CharField(),
},
)
)
@api_view(["GET"])
@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(
Expand Down
Loading