Skip to content

Commit b89b695

Browse files
committed
Implement v2 views for modules/owners
- Retrieving Safes by modules/owners in v1 was not paginated - Being paginated allows the service to handle better owners/modules with a high number of Safes - Add v2 views and tests
1 parent a020fc8 commit b89b695

File tree

6 files changed

+326
-4
lines changed

6 files changed

+326
-4
lines changed

safe_transaction_service/history/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2313,6 +2313,20 @@ def addresses_for_owner(self, owner_address: str) -> QuerySet[str]:
23132313
"address", flat=True
23142314
)
23152315

2316+
def safes_for_owner(self, owner_address: str) -> QuerySet["SafeLastStatus"]:
2317+
"""
2318+
:param owner_address:
2319+
:return: SafeLastStatus queryset where the provided `owner_address` is an owner
2320+
"""
2321+
return self.filter(owners__contains=[owner_address])
2322+
2323+
def safes_for_module(self, module_address: str) -> QuerySet["SafeLastStatus"]:
2324+
"""
2325+
:param module_address:
2326+
:return: SafeLastStatus queryset where the provided `module_address` is enabled
2327+
"""
2328+
return self.filter(enabled_modules__contains=[module_address])
2329+
23162330

23172331
class SafeLastStatus(SafeStatusBase):
23182332
"""Latest known Safe state cached for quick access."""

safe_transaction_service/history/serializers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,17 @@ class OwnerResponseSerializer(serializers.Serializer):
914914
safes = serializers.ListField(child=EthereumAddressField())
915915

916916

917+
class SafeLastStatusSerializer(serializers.Serializer):
918+
address = EthereumAddressField()
919+
owners = serializers.ListField(child=EthereumAddressField())
920+
threshold = serializers.IntegerField()
921+
nonce = serializers.IntegerField()
922+
master_copy = EthereumAddressField()
923+
fallback_handler = EthereumAddressField()
924+
guard = EthereumAddressField(allow_null=True)
925+
enabled_modules = serializers.ListField(child=EthereumAddressField())
926+
927+
917928
class TransferType(Enum):
918929
ETHER_TRANSFER = 0
919930
ERC20_TRANSFER = 1

safe_transaction_service/history/tests/test_views_v2.py

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@
3030
from ...tokens.tests.factories import TokenFactory
3131
from ...utils.utils import datetime_to_str
3232
from ..helpers import DelegateSignatureHelperV2, DeleteMultisigTxSignatureHelper
33-
from ..models import MultisigConfirmation, MultisigTransaction, SafeContractDelegate
33+
from ..models import (
34+
MultisigConfirmation,
35+
MultisigTransaction,
36+
SafeContractDelegate,
37+
SafeLastStatus,
38+
)
3439
from ..serializers import TransferType
3540
from ..views_v2 import SafeMultisigTransactionListView
3641
from .factories import (
@@ -43,6 +48,8 @@
4348
MultisigTransactionFactory,
4449
SafeContractDelegateFactory,
4550
SafeContractFactory,
51+
SafeLastStatusFactory,
52+
SafeStatusFactory,
4653
)
4754

4855

@@ -2610,3 +2617,190 @@ def test_all_transactions_duplicated_module_view(self):
26102617
{module_transaction_1.module, module_transaction_2.module},
26112618
{module_tx["module"] for module_tx in response.data["results"]},
26122619
)
2620+
2621+
def test_owners_view_v2(self):
2622+
"""Test OwnersViewV2 with pagination and all SafeLastStatus fields"""
2623+
invalid_address = "0x2A"
2624+
response = self.client.get(
2625+
reverse("v2:history:owners", args=(invalid_address,))
2626+
)
2627+
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
2628+
self.assertEqual(response.data["code"], 1)
2629+
self.assertEqual(response.data["message"], "Checksum address validation failed")
2630+
2631+
owner_address = Account.create().address
2632+
response = self.client.get(
2633+
reverse("v2:history:owners", args=(owner_address,)), format="json"
2634+
)
2635+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2636+
self.assertEqual(response.data["count"], 0)
2637+
self.assertEqual(response.data["results"], [])
2638+
2639+
# Create SafeLastStatus with the owner
2640+
safe_last_status = SafeLastStatusFactory(owners=[owner_address])
2641+
response = self.client.get(
2642+
reverse("v2:history:owners", args=(owner_address,)), format="json"
2643+
)
2644+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2645+
self.assertEqual(response.data["count"], 1)
2646+
self.assertEqual(len(response.data["results"]), 1)
2647+
2648+
# Verify all SafeLastStatus fields are present
2649+
result = response.data["results"][0]
2650+
self.assertEqual(result["address"], safe_last_status.address)
2651+
self.assertCountEqual(result["owners"], safe_last_status.owners)
2652+
self.assertEqual(result["threshold"], safe_last_status.threshold)
2653+
self.assertEqual(result["nonce"], safe_last_status.nonce)
2654+
self.assertEqual(result["master_copy"], safe_last_status.master_copy)
2655+
self.assertEqual(result["fallback_handler"], safe_last_status.fallback_handler)
2656+
self.assertEqual(result["guard"], safe_last_status.guard)
2657+
self.assertCountEqual(
2658+
result["enabled_modules"], safe_last_status.enabled_modules
2659+
)
2660+
2661+
# Test with multiple safes
2662+
safe_status_2 = SafeLastStatusFactory(owners=[owner_address])
2663+
SafeStatusFactory() # Test that other SafeStatus don't appear
2664+
response = self.client.get(
2665+
reverse("v2:history:owners", args=(owner_address,)), format="json"
2666+
)
2667+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2668+
self.assertEqual(response.data["count"], 2)
2669+
self.assertEqual(len(response.data["results"]), 2)
2670+
addresses = [result["address"] for result in response.data["results"]]
2671+
self.assertCountEqual(
2672+
addresses, [safe_last_status.address, safe_status_2.address]
2673+
)
2674+
2675+
# Test pagination
2676+
_safe_status_3 = SafeLastStatusFactory(owners=[owner_address])
2677+
response = self.client.get(
2678+
reverse("v2:history:owners", args=(owner_address,)) + "?limit=2&offset=0",
2679+
format="json",
2680+
)
2681+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2682+
self.assertEqual(response.data["count"], 3)
2683+
self.assertEqual(len(response.data["results"]), 2)
2684+
self.assertIsNotNone(response.data["next"])
2685+
self.assertIsNone(response.data["previous"])
2686+
2687+
# Test next page
2688+
response = self.client.get(
2689+
reverse("v2:history:owners", args=(owner_address,)) + "?limit=2&offset=2",
2690+
format="json",
2691+
)
2692+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2693+
self.assertEqual(response.data["count"], 3)
2694+
self.assertEqual(len(response.data["results"]), 1)
2695+
self.assertIsNone(response.data["next"])
2696+
self.assertIsNotNone(response.data["previous"])
2697+
2698+
# Test that safes without the owner don't appear
2699+
other_owner = Account.create().address
2700+
SafeLastStatusFactory(owners=[other_owner])
2701+
response = self.client.get(
2702+
reverse("v2:history:owners", args=(owner_address,)), format="json"
2703+
)
2704+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2705+
self.assertEqual(response.data["count"], 3)
2706+
addresses = [result["address"] for result in response.data["results"]]
2707+
self.assertNotIn(
2708+
SafeLastStatus.objects.filter(owners__contains=[other_owner])
2709+
.first()
2710+
.address,
2711+
addresses,
2712+
)
2713+
2714+
def test_modules_view_v2(self):
2715+
"""Test ModulesViewV2 with pagination and all SafeLastStatus fields"""
2716+
invalid_address = "0x2A"
2717+
response = self.client.get(
2718+
reverse("v2:history:modules", args=(invalid_address,))
2719+
)
2720+
self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
2721+
self.assertEqual(response.data["code"], 1)
2722+
self.assertEqual(response.data["message"], "Checksum address validation failed")
2723+
2724+
module_address = Account.create().address
2725+
response = self.client.get(
2726+
reverse("v2:history:modules", args=(module_address,)), format="json"
2727+
)
2728+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2729+
self.assertEqual(response.data["count"], 0)
2730+
self.assertEqual(response.data["results"], [])
2731+
2732+
# Create SafeLastStatus with the module
2733+
safe_last_status = SafeLastStatusFactory(enabled_modules=[module_address])
2734+
response = self.client.get(
2735+
reverse("v2:history:modules", args=(module_address,)), format="json"
2736+
)
2737+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2738+
self.assertEqual(response.data["count"], 1)
2739+
self.assertEqual(len(response.data["results"]), 1)
2740+
2741+
# Verify all SafeLastStatus fields are present
2742+
result = response.data["results"][0]
2743+
self.assertEqual(result["address"], safe_last_status.address)
2744+
self.assertCountEqual(result["owners"], safe_last_status.owners)
2745+
self.assertEqual(result["threshold"], safe_last_status.threshold)
2746+
self.assertEqual(result["nonce"], safe_last_status.nonce)
2747+
self.assertEqual(result["master_copy"], safe_last_status.master_copy)
2748+
self.assertEqual(result["fallback_handler"], safe_last_status.fallback_handler)
2749+
self.assertEqual(result["guard"], safe_last_status.guard)
2750+
self.assertCountEqual(
2751+
result["enabled_modules"], safe_last_status.enabled_modules
2752+
)
2753+
self.assertIn(module_address, result["enabled_modules"])
2754+
2755+
# Test with multiple safes
2756+
safe_status_2 = SafeLastStatusFactory(enabled_modules=[module_address])
2757+
SafeStatusFactory() # Test that other SafeStatus don't appear
2758+
response = self.client.get(
2759+
reverse("v2:history:modules", args=(module_address,)), format="json"
2760+
)
2761+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2762+
self.assertEqual(response.data["count"], 2)
2763+
self.assertEqual(len(response.data["results"]), 2)
2764+
addresses = [result["address"] for result in response.data["results"]]
2765+
self.assertCountEqual(
2766+
addresses, [safe_last_status.address, safe_status_2.address]
2767+
)
2768+
2769+
# Test pagination
2770+
_safe_status_3 = SafeLastStatusFactory(enabled_modules=[module_address])
2771+
response = self.client.get(
2772+
reverse("v2:history:modules", args=(module_address,)) + "?limit=2&offset=0",
2773+
format="json",
2774+
)
2775+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2776+
self.assertEqual(response.data["count"], 3)
2777+
self.assertEqual(len(response.data["results"]), 2)
2778+
self.assertIsNotNone(response.data["next"])
2779+
self.assertIsNone(response.data["previous"])
2780+
2781+
# Test next page
2782+
response = self.client.get(
2783+
reverse("v2:history:modules", args=(module_address,)) + "?limit=2&offset=2",
2784+
format="json",
2785+
)
2786+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2787+
self.assertEqual(response.data["count"], 3)
2788+
self.assertEqual(len(response.data["results"]), 1)
2789+
self.assertIsNone(response.data["next"])
2790+
self.assertIsNotNone(response.data["previous"])
2791+
2792+
# Test that safes without the module don't appear
2793+
other_module = Account.create().address
2794+
SafeLastStatusFactory(enabled_modules=[other_module])
2795+
response = self.client.get(
2796+
reverse("v2:history:modules", args=(module_address,)), format="json"
2797+
)
2798+
self.assertEqual(response.status_code, status.HTTP_200_OK)
2799+
self.assertEqual(response.data["count"], 3)
2800+
addresses = [result["address"] for result in response.data["results"]]
2801+
self.assertNotIn(
2802+
SafeLastStatus.objects.filter(enabled_modules__contains=[other_module])
2803+
.first()
2804+
.address,
2805+
addresses,
2806+
)

safe_transaction_service/history/urls_v2.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,14 @@
3636
views_v2.AllTransactionsListView.as_view(),
3737
name="all-transactions",
3838
),
39+
path(
40+
"owners/<str:address>/safes/",
41+
views_v2.OwnersViewV2.as_view(),
42+
name="owners",
43+
),
44+
path(
45+
"modules/<str:address>/safes/",
46+
views_v2.ModulesViewV2.as_view(),
47+
name="modules",
48+
),
3949
]

safe_transaction_service/history/views.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,13 +1134,14 @@ class ModulesView(GenericAPIView):
11341134
pagination_class = None # Don't show limit/offset in swagger
11351135

11361136
@extend_schema(
1137+
deprecated=True,
11371138
responses={
11381139
200: serializers.ModulesResponseSerializer(),
11391140
422: OpenApiResponse(
11401141
response=serializers.CodeErrorResponse,
11411142
description="Module address checksum not valid",
11421143
),
1143-
}
1144+
},
11441145
)
11451146
@method_decorator(cache_page(15)) # 15 seconds
11461147
def get(self, request, address, *args, **kwargs):
@@ -1168,13 +1169,14 @@ class OwnersView(GenericAPIView):
11681169
pagination_class = None # Don't show limit/offset in swagger
11691170

11701171
@extend_schema(
1172+
deprecated=True,
11711173
responses={
11721174
200: serializers.OwnerResponseSerializer(),
11731175
422: OpenApiResponse(
11741176
response=serializers.CodeErrorResponse,
11751177
description="Owner address checksum not valid",
11761178
),
1177-
}
1179+
},
11781180
)
11791181
@method_decorator(cache_page(15)) # 15 seconds
11801182
def get(self, request, address, *args, **kwargs):

0 commit comments

Comments
 (0)