diff --git a/api/.env-sample b/api/.env-sample index bf8b8b0cb..76ee14d92 100644 --- a/api/.env-sample +++ b/api/.env-sample @@ -33,3 +33,4 @@ CERAMIC_CACHE_SCORER_ID= PASSPORT_PUBLIC_URL=https://passport.gitcoin.co/ TRUSTED_IAM_ISSUER= +CGRANTS_API_TOKEN=abc diff --git a/api/cgrants/api.py b/api/cgrants/api.py index 864ef04a9..5d6fc7c03 100644 --- a/api/cgrants/api.py +++ b/api/cgrants/api.py @@ -6,50 +6,30 @@ import logging from datetime import datetime +from django.conf import settings from django.db.models import Sum from django.http import JsonResponse from ninja_extra import NinjaExtraAPI from ninja_schema import Schema +from ninja.security import APIKeyHeader from .models import Contribution, Grant, GrantContributionIndex, SquelchProfile -# from perftools.models import StaticJsonEnv - logger = logging.getLogger(__name__) api = NinjaExtraAPI(urls_namespace="cgrants") -# def ami_api_token_required(func): -# def decorator(request, *args, **kwargs): -# try: -# apiToken = StaticJsonEnv.objects.get(key="AMI_API_TOKEN") -# expectedToken = apiToken.data["token"] -# receivedToken = request.headers.get("Authorization") - -# if receivedToken: -# # Token shall look like "token ", and we need only the part -# receivedToken = receivedToken.split(" ")[1] - -# if expectedToken == receivedToken: -# return func(request, *args, **kwargs) -# else: -# return JsonResponse( -# { -# "error": "Access denied", -# }, -# status=403, -# ) -# except Exception as e: -# logger.error("Error in ami_api_token_required %s", e) -# return JsonResponse( -# { -# "error": "An unexpected error occured", -# }, -# status=500, -# ) - -# return decorator + +class CgrantsApiKey(APIKeyHeader): + param_name = "AUTHORIZATION" + + def authenticate(self, request, key): + if key == settings.CGRANTS_API_TOKEN: + return key + + +cg_api_key = CgrantsApiKey() class ContributorStatistics(Schema): @@ -66,8 +46,11 @@ class GranteeStatistics(Schema): total_contribution_amount = int -# TODO add auth -@api.get("/contributor_statistics", response=ContributorStatistics) +@api.get( + "/contributor_statistics", + response=ContributorStatistics, + auth=cg_api_key, +) def contributor_statistics(request): handle = request.GET.get("handle") @@ -124,8 +107,11 @@ def contributor_statistics(request): ) -# TODO add auth -@api.get("/grantee_statistics", response=GranteeStatistics) +@api.get( + "/grantee_statistics", + response=GranteeStatistics, + auth=cg_api_key, +) def grantee_statistics(request): handle = request.GET.get("handle") diff --git a/api/cgrants/test/test_cgrants_api.py b/api/cgrants/test/test_cgrants_api.py new file mode 100644 index 000000000..6c6d16d32 --- /dev/null +++ b/api/cgrants/test/test_cgrants_api.py @@ -0,0 +1,136 @@ +from django.test import TestCase, Client +from django.conf import settings +from django.urls import reverse +from cgrants.models import ( + Profile, + Grant, + GrantContributionIndex, + Contribution, + Subscription, + SquelchProfile, +) + + +class CgrantsTest(TestCase): + def setUp(self): + self.client = Client() + + self.headers = {"HTTP_AUTHORIZATION": settings.CGRANTS_API_TOKEN} + + self.profile1 = Profile.objects.create(handle="user1") + self.profile2 = Profile.objects.create(handle="user2") + self.profile3 = Profile.objects.create(handle="user3") + + self.grant1 = Grant.objects.create( + admin_profile=self.profile1, hidden=False, active=True, is_clr_eligible=True + ) + + self.subscription1 = Subscription.objects.create( + grant=self.grant1, contributor_profile=self.profile1 + ) + Contribution.objects.create(subscription=self.subscription1) + + # create test grant contribution indexes + GrantContributionIndex.objects.create( + profile=self.profile1, grant=self.grant1, amount=100 + ) + + SquelchProfile.objects.create(profile=self.profile3, active=True) + + def test_contributor_statistics(self): + # Standard case + response = self.client.get( + reverse("cgrants:contributor_statistics"), + {"handle": "user1"}, + **self.headers, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "num_grants_contribute_to": 1, + "num_rounds_contribute_to": 0, + "total_contribution_amount": "100.000000000000000000", + "num_gr14_contributions": 0, + }, + ) + + def test_contributor_statistics_no_contributions(self): + # Edge case: User has made no contributions + response = self.client.get( + reverse("cgrants:contributor_statistics"), + {"handle": "user2"}, + **self.headers, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "num_grants_contribute_to": 0, + "num_rounds_contribute_to": 0, + "total_contribution_amount": 0, + "num_gr14_contributions": 0, + }, + ) + + def test_grantee_statistics_standard(self): + # Standard case + response = self.client.get( + reverse("cgrants:grantee_statistics"), + {"handle": "user1"}, + **self.headers, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "num_owned_grants": 1, + "num_grant_contributors": 1, + "num_grants_in_eco_and_cause_rounds": 0, + "total_contribution_amount": 0, + }, + ) + + def test_grantee_statistics_no_grants(self): + response = self.client.get( + reverse("cgrants:grantee_statistics"), + {"handle": "user2"}, + **self.headers, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "num_owned_grants": 0, + "num_grant_contributors": 0, + "num_grants_in_eco_and_cause_rounds": 0, + "total_contribution_amount": 0, + }, + ) + + def test_invalid_handle(self): + response = self.client.get( + reverse("cgrants:contributor_statistics"), + {"handle": ""}, + **self.headers, + ) + self.assertEqual(response.status_code, 400) # Not found + + def test_contributor_statistics_squelched_profile(self): + response = self.client.get( + reverse("cgrants:contributor_statistics"), + {"handle": self.profile3.handle}, + **self.headers, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("num_gr14_contributions"), 0) + + def test_grantee_statistics_invalid_token(self): + response = self.client.get( + reverse("cgrants:contributor_statistics"), + {"handle": self.profile1.handle}, + **{"HTTP_AUTHORIZATION": "invalidtoken"}, + ) + + self.assertEqual(response.status_code, 401) diff --git a/api/cgrants/tests.py b/api/cgrants/tests.py deleted file mode 100644 index 7ce503c2d..000000000 --- a/api/cgrants/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/api/cgrants/urls.py b/api/cgrants/urls.py new file mode 100644 index 000000000..724f6a45a --- /dev/null +++ b/api/cgrants/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .api import api + +urlpatterns = [ + path("", api.urls), +] diff --git a/api/scorer/settings/base.py b/api/scorer/settings/base.py index b81ab8ae4..00ffc7f53 100644 --- a/api/scorer/settings/base.py +++ b/api/scorer/settings/base.py @@ -384,6 +384,8 @@ "TRUSTED_IAM_ISSUER", default="did:key:GlMY_1zkc0i11O-wMBWbSiUfIkZiXzFLlAQ89pdfyBA" ) +CGRANTS_API_TOKEN = env("CGRANTS_API_TOKEN", default="abc") + IPWARE_META_PRECEDENCE_ORDER = ( "X_FORWARDED_FOR", diff --git a/api/scorer/urls.py b/api/scorer/urls.py index 0b4601009..bd81589b6 100644 --- a/api/scorer/urls.py +++ b/api/scorer/urls.py @@ -32,6 +32,7 @@ path("registry/v2/", registry_api_v2.urls), path("registry/feature/", feature_flag_api.urls), path("ceramic-cache/", ceramic_cache_api.urls), + path("cgrants/", include("cgrants.urls")), path("analytics/", analytics_api.urls), path("health/", health, {}, "health-check"), path( diff --git a/infra/prod/index.ts b/infra/prod/index.ts index 9b89e3bc0..941d42dd5 100644 --- a/infra/prod/index.ts +++ b/infra/prod/index.ts @@ -380,6 +380,10 @@ const secrets = [ name: "FF_API_ANALYTICS", valueFrom: `${SCORER_SERVER_SSM_ARN}:FF_API_ANALYTICS::`, }, + { + name: "CGRANTS_API_TOKEN", + valueFrom: `${SCORER_SERVER_SSM_ARN}:CGRANTS_API_TOKEN::`, + }, ]; const environment = [ { diff --git a/infra/review/index.ts b/infra/review/index.ts index 484455794..468714dba 100644 --- a/infra/review/index.ts +++ b/infra/review/index.ts @@ -308,6 +308,10 @@ const secrets = [ name: "FF_API_ANALYTICS", valueFrom: `${SCORER_SERVER_SSM_ARN}:FF_API_ANALYTICS::`, }, + { + name: "CGRANTS_API_TOKEN", + valueFrom: `${SCORER_SERVER_SSM_ARN}:CGRANTS_API_TOKEN::`, + }, ]; const environment = [ { diff --git a/infra/staging/index.ts b/infra/staging/index.ts index 8c87bb423..66fe55fc4 100644 --- a/infra/staging/index.ts +++ b/infra/staging/index.ts @@ -312,6 +312,10 @@ const secrets = [ name: "FF_API_ANALYTICS", valueFrom: `${SCORER_SERVER_SSM_ARN}:FF_API_ANALYTICS::`, }, + { + name: "CGRANTS_API_TOKEN", + valueFrom: `${SCORER_SERVER_SSM_ARN}:CGRANTS_API_TOKEN::`, + }, ]; const environment = [ {