diff --git a/api/cgrants/api.py b/api/cgrants/api.py index 2fa1d6e8a..e230096a3 100644 --- a/api/cgrants/api.py +++ b/api/cgrants/api.py @@ -112,20 +112,21 @@ def _get_contributor_statistics_for_cgrants( def _get_contributor_statistics_for_protocol(address: str) -> dict: - total_amount_usd = ProtocolContributions.objects.filter( - contributor=address - ).aggregate(Sum("amount"))["amount__sum"] - num_rounds = ProtocolContributions.objects.filter(contributor=address).aggregate( - Count("round", distinct=True) - )["round__count"] - num_projects = ProtocolContributions.objects.filter(contributor=address).aggregate( - Count("project", distinct=True) - )["project__count"] + protocol_filter = ProtocolContributions.objects.filter( + contributor=address, amount__gte=1 + ) + total_amount_usd = protocol_filter.aggregate(Sum("amount"))["amount__sum"] + num_rounds = protocol_filter.aggregate(Count("round", distinct=True))[ + "round__count" + ] + num_projects = protocol_filter.aggregate(Count("project", distinct=True))[ + "project__count" + ] return { "num_grants_contribute_to": num_projects if num_projects is not None else 0, "num_rounds_contribute_to": num_rounds if num_rounds is not None else 0, - "total_contribution_amount": total_amount_usd + "total_valid_contribution_amount": round(total_amount_usd, 3) if total_amount_usd is not None else 0, "num_gr14_contributions": 0, diff --git a/api/cgrants/test/test_cgrants_api.py b/api/cgrants/test/test_cgrants_api.py index 4e60ea4fb..b90b19ea2 100644 --- a/api/cgrants/test/test_cgrants_api.py +++ b/api/cgrants/test/test_cgrants_api.py @@ -270,7 +270,34 @@ def test_contributor_statistics_with_only_protocol_contributions(self): { "num_grants_contribute_to": 4, "num_rounds_contribute_to": 2, - "total_contribution_amount": "10", + "total_valid_contribution_amount": "10.000", + "num_gr14_contributions": 0, + }, + ) + + def test_contributor_statistics_with_below_threshold_contributions_contributions( + self, + ): + existing_contributions = ProtocolContributions.objects.filter( + contributor=self.address + ).all() + for contribution in existing_contributions: + contribution.amount = 0.5 + contribution.save() + + # Edge case: User has made no contributions + response = self.client.get( + reverse("cgrants:allo_contributor_statistics"), + {"address": self.address}, + **self.headers, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "num_grants_contribute_to": 0, + "num_rounds_contribute_to": 0, + "total_valid_contribution_amount": 0, "num_gr14_contributions": 0, }, ) diff --git a/api/scorer/settings/base.py b/api/scorer/settings/base.py index c54afa203..85aaa3c9f 100644 --- a/api/scorer/settings/base.py +++ b/api/scorer/settings/base.py @@ -85,6 +85,7 @@ # "debug_toolbar", "cgrants", "django_filters", + "trusta_labs", ] AUTHENTICATION_BACKENDS = [ diff --git a/api/scorer/settings/gitcoin_passport_weights.py b/api/scorer/settings/gitcoin_passport_weights.py index d9c747f25..19cbe9949 100644 --- a/api/scorer/settings/gitcoin_passport_weights.py +++ b/api/scorer/settings/gitcoin_passport_weights.py @@ -80,6 +80,13 @@ "CyberProfilePremium": "1.21", "CyberProfilePaid": "1.21", "CyberProfileOrgMember": "1.21", + "GrantsStack3Projects": "1.07", + "GrantsStack5Projects": "1.07", + "GrantsStack7Projects": "1.07", + "GrantsStack2Programs": "1.07", + "GrantsStack4Programs": "1.07", + "GrantsStack6Programs": "1.07", + "TrustaLabs": "1.54", } diff --git a/api/scorer/urls.py b/api/scorer/urls.py index bae5f4e74..72a84d84b 100644 --- a/api/scorer/urls.py +++ b/api/scorer/urls.py @@ -46,4 +46,5 @@ path("social/", include("social_django.urls", namespace="social")), path("passport-admin/", passport_admin_api.urls), # path("__debug__/", include("debug_toolbar.urls")), + path("trusta_labs/", include("trusta_labs.urls")), ] diff --git a/api/trusta_labs/__init__.py b/api/trusta_labs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/trusta_labs/admin.py b/api/trusta_labs/admin.py new file mode 100644 index 000000000..b5bc4f6e4 --- /dev/null +++ b/api/trusta_labs/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from trusta_labs.models import TrustaLabsScore + + +class TrustaLabsScoreAdmin(admin.ModelAdmin): + list_display = ["address", "sybil_risk_score"] + search_fields = ["address"] + search_help_text = "This will perform an exact case insensitive search by 'address'" + show_full_result_count = False + + +admin.site.register(TrustaLabsScore, TrustaLabsScoreAdmin) diff --git a/api/trusta_labs/api.py b/api/trusta_labs/api.py new file mode 100644 index 000000000..b45fe210d --- /dev/null +++ b/api/trusta_labs/api.py @@ -0,0 +1,60 @@ +from django.conf import settings +from ninja import Schema +from ninja.security import APIKeyHeader +from ninja_extra import NinjaExtraAPI, status +from ninja_extra.exceptions import APIException +from trusta_labs.models import TrustaLabsScore + +api = NinjaExtraAPI(urls_namespace="trusta_labs") + + +class CgrantsApiKey(APIKeyHeader): + param_name = "AUTHORIZATION" + + def authenticate(self, request, key): + if key == settings.CGRANTS_API_TOKEN: + return key + + +cg_api_key = CgrantsApiKey() + + +class TrustaLabsScorePayload(Schema): + address: str + score: int + + +class TrustaLabsScoreResponse(Schema): + address: str + score: int + + +class TrustaLabsScoreHasNoPayload(APIException): + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + default_detail = "There is no payload with this request" + + +class TrustaLabsScoreHasNoAddress(APIException): + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + default_detail = "A Trusta Lab score must be accompanied by an address" + + +class TrustaLabsScoreHasNoScore(APIException): + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + default_detail = "A Trusta Lab request must include a score" + + +@api.post("/trusta-labs-score", auth=cg_api_key) +def create_trusta_labs_score_db(request, payload: TrustaLabsScorePayload): + if payload == None: + raise TrustaLabsScoreHasNoPayload() + + if payload.address == None: + raise TrustaLabsScoreHasNoAddress() + + if payload.score == None: + raise TrustaLabsScoreHasNoScore() + + TrustaLabsScore.objects.update_or_create( + address=payload.address, sybil_risk_score=payload.score + ) diff --git a/api/trusta_labs/apps.py b/api/trusta_labs/apps.py new file mode 100644 index 000000000..8b1da74d7 --- /dev/null +++ b/api/trusta_labs/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TrustaLabsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "trusta_labs" diff --git a/api/trusta_labs/migrations/0001_initial.py b/api/trusta_labs/migrations/0001_initial.py new file mode 100644 index 000000000..e0b8b06de --- /dev/null +++ b/api/trusta_labs/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.3 on 2023-08-14 17:52 + +import account.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="TrustaLabsScore", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "address", + account.models.EthAddressField( + db_index=True, max_length=100, null=True + ), + ), + ("sybil_risk_score", models.IntegerField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/api/trusta_labs/migrations/__init__.py b/api/trusta_labs/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/trusta_labs/models.py b/api/trusta_labs/models.py new file mode 100644 index 000000000..6104ac941 --- /dev/null +++ b/api/trusta_labs/models.py @@ -0,0 +1,12 @@ +from account.models import EthAddressField +from django.db import models + +# WHEN a Trusta Labs API call is made for verification, +# THEN the result of the call, including timestamps, passport addresses, and Trusta Labs scores, should be logged in a dedicated table, separate from the credential issuance. + + +class TrustaLabsScore(models.Model): + address = EthAddressField(null=True, blank=False, max_length=100, db_index=True) + sybil_risk_score = models.IntegerField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) diff --git a/api/trusta_labs/test/test_trusta_labs_score.py b/api/trusta_labs/test/test_trusta_labs_score.py new file mode 100644 index 000000000..e014a0916 --- /dev/null +++ b/api/trusta_labs/test/test_trusta_labs_score.py @@ -0,0 +1,36 @@ +import json +from datetime import datetime, timezone +from typing import cast + +from django.conf import settings +from django.test import Client, TestCase +from trusta_labs.models import TrustaLabsScore + +mock_trusta_labs_score_body = { + "address": "0x8u3eu3ydh3rydh3irydhu", + "score": 20, +} + + +class TrustaLabsScoreTestCase(TestCase): + def test_create_trusta_labs_score(self): + self.headers = {"HTTP_AUTHORIZATION": settings.CGRANTS_API_TOKEN} + """Test that creation of a trusta lab score works and saved correctly""" + client = Client() + trusta_labs_response = client.post( + "/trusta_labs/trusta-labs-score", + json.dumps(mock_trusta_labs_score_body), + content_type="application/json", + **self.headers + ) + self.assertEqual(trusta_labs_response.status_code, 200) + + # Check that the trusta lab score was created + all_trusta_labs_scores = list(TrustaLabsScore.objects.all()) + self.assertEqual(len(all_trusta_labs_scores), 1) + score = all_trusta_labs_scores[0] + self.assertEqual(score.address, mock_trusta_labs_score_body["address"]) + self.assertEqual(score.sybil_risk_score, mock_trusta_labs_score_body["score"]) + + def test_error_creating_trusta_lab_score(self): + pass diff --git a/api/trusta_labs/urls.py b/api/trusta_labs/urls.py new file mode 100644 index 000000000..724f6a45a --- /dev/null +++ b/api/trusta_labs/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .api import api + +urlpatterns = [ + path("", api.urls), +]