diff --git a/api/gen_ai/__init__.py b/api/gen_ai/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/gen_ai/serializers.py b/api/gen_ai/serializers.py new file mode 100644 index 0000000000..1b7ceec225 --- /dev/null +++ b/api/gen_ai/serializers.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + + +class GenAIAuthSerializer(serializers.Serializer): + is_valid = serializers.BooleanField() diff --git a/api/gen_ai/tests/test_gen_ai.py b/api/gen_ai/tests/test_gen_ai.py new file mode 100644 index 0000000000..4b63d51d74 --- /dev/null +++ b/api/gen_ai/tests/test_gen_ai.py @@ -0,0 +1,114 @@ +import hmac +from hashlib import sha256 +from unittest.mock import patch + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from shared.django_apps.core.tests.factories import OwnerFactory + +from codecov_auth.models import GithubAppInstallation + +PAYLOAD_SECRET = b"testixik8qdauiab1yiffydimvi72ekq" +VIEW_URL = reverse("auth") + + +def sign_payload(data: bytes, secret=PAYLOAD_SECRET): + signature = "sha256=" + hmac.new(secret, data, digestmod=sha256).hexdigest() + return signature, data + + +class GenAIAuthViewTests(APITestCase): + @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET) + def test_missing_parameters(self, mock_config): + payload = b"{}" + sig, data = sign_payload(payload) + response = self.client.post( + VIEW_URL, + data=data, + content_type="application/json", + HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig, + ) + self.assertEqual(response.status_code, 400) + self.assertIn("Missing required parameters", response.data) + + @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET) + def test_invalid_signature(self, mock_config): + # Correct payload + payload = b'{"external_owner_id":"owner1","repo_service_id":"101"}' + # Wrong signature based on a different payload + wrong_sig = "sha256=" + hmac.new(PAYLOAD_SECRET, b"{}", sha256).hexdigest() + response = self.client.post( + VIEW_URL, + data=payload, + content_type="application/json", + HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=wrong_sig, + ) + self.assertEqual(response.status_code, 403) + + @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET) + def test_owner_not_found(self, mock_config): + payload = b'{"external_owner_id":"nonexistent_owner","repo_service_id":"101"}' + sig, data = sign_payload(payload) + response = self.client.post( + VIEW_URL, + data=data, + content_type="application/json", + HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig, + ) + self.assertEqual(response.status_code, 404) + + @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET) + def test_no_installation(self, mock_config): + # Create a valid owner but no installation + OwnerFactory(service="github", service_id="owner1", username="test1") + payload = b'{"external_owner_id":"owner1","repo_service_id":"101"}' + sig, data = sign_payload(payload) + response = self.client.post( + VIEW_URL, + data=data, + content_type="application/json", + HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, {"is_valid": False}) + + @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET) + def test_authorized(self, mock_config): + owner = OwnerFactory(service="github", service_id="owner2", username="test2") + GithubAppInstallation.objects.create( + installation_id=12345, + owner=owner, + name="ai-features", + repository_service_ids=["101", "202"], + ) + payload = b'{"external_owner_id":"owner2","repo_service_id":"101"}' + sig, data = sign_payload(payload) + response = self.client.post( + VIEW_URL, + data=data, + content_type="application/json", + HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {"is_valid": True}) + + @patch("api.gen_ai.views.get_config", return_value=PAYLOAD_SECRET) + def test_unauthorized(self, mock_config): + owner = OwnerFactory(service="github", service_id="owner3", username="test3") + GithubAppInstallation.objects.create( + installation_id=2, + owner=owner, + name="ai-features", + repository_service_ids=["303", "404"], + ) + payload = b'{"external_owner_id":"owner3","repo_service_id":"101"}' + sig, data = sign_payload(payload) + response = self.client.post( + VIEW_URL, + data=data, + content_type="application/json", + HTTP_HTTP_X_GEN_AI_AUTH_SIGNATURE=sig, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, {"is_valid": False}) diff --git a/api/gen_ai/urls.py b/api/gen_ai/urls.py new file mode 100644 index 0000000000..1273948008 --- /dev/null +++ b/api/gen_ai/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import GenAIAuthView + +urlpatterns = [ + path("auth/", GenAIAuthView.as_view(), name="auth"), +] diff --git a/api/gen_ai/views.py b/api/gen_ai/views.py new file mode 100644 index 0000000000..e857c5a3a9 --- /dev/null +++ b/api/gen_ai/views.py @@ -0,0 +1,61 @@ +import hmac +import logging +from hashlib import sha256 + +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.gen_ai.serializers import GenAIAuthSerializer +from codecov_auth.models import GithubAppInstallation, Owner +from graphql_api.types.owner.owner import AI_FEATURES_GH_APP_ID +from utils.config import get_config + +log = logging.getLogger(__name__) + + +class GenAIAuthView(APIView): + permission_classes = [AllowAny] + serializer_class = GenAIAuthSerializer + + def validate_signature(self, request): + key = get_config("gen_ai", "auth_secret") + if not key: + raise PermissionDenied("Invalid signature") + + if isinstance(key, str): + key = key.encode("utf-8") + expected_sig = request.headers.get("HTTP-X-GEN-AI-AUTH-SIGNATURE") + computed_sig = ( + "sha256=" + hmac.new(key, request.body, digestmod=sha256).hexdigest() + ) + if not hmac.compare_digest(computed_sig, expected_sig): + raise PermissionDenied("Invalid signature") + + def post(self, request, *args, **kwargs): + self.validate_signature(request) + external_owner_id = request.data.get("external_owner_id") + repo_service_id = request.data.get("repo_service_id") + if not external_owner_id or not repo_service_id: + return Response("Missing required parameters", status=400) + try: + owner = Owner.objects.get(service_id=external_owner_id) + except Owner.DoesNotExist: + raise NotFound("Owner not found") + + is_authorized = True + + app_install = GithubAppInstallation.objects.filter( + owner_id=owner.ownerid, app_id=AI_FEATURES_GH_APP_ID + ).first() + + if not app_install: + is_authorized = False + + else: + repo_ids = app_install.repository_service_ids + if repo_ids and repo_service_id not in repo_ids: + is_authorized = False + + return Response({"is_valid": is_authorized}) diff --git a/codecov/urls.py b/codecov/urls.py index b718d4355d..4b1ab5bc19 100644 --- a/codecov/urls.py +++ b/codecov/urls.py @@ -37,4 +37,5 @@ # /monitoring/metrics will be a public route unless you take steps at a # higher level to null-route or redirect it. path("monitoring/", include("django_prometheus.urls")), + path("gen_ai/", include("api.gen_ai.urls")), ] diff --git a/codecov_auth/commands/owner/interactors/get_is_current_user_an_admin.py b/codecov_auth/commands/owner/interactors/get_is_current_user_an_admin.py index 975e39041e..46906de909 100644 --- a/codecov_auth/commands/owner/interactors/get_is_current_user_an_admin.py +++ b/codecov_auth/commands/owner/interactors/get_is_current_user_an_admin.py @@ -50,5 +50,5 @@ def execute(self, owner, current_owner): owner.add_admin(current_owner) return isAdmin or (current_owner.ownerid in admins) except Exception as error: - print("Error Calling Admin Provider " + repr(error)) + print("Error Calling Admin Provider " + repr(error)) # noqa: T201 return False diff --git a/codecov_auth/commands/owner/interactors/save_terms_agreement.py b/codecov_auth/commands/owner/interactors/save_terms_agreement.py index e54b73c528..4df6d79db3 100644 --- a/codecov_auth/commands/owner/interactors/save_terms_agreement.py +++ b/codecov_auth/commands/owner/interactors/save_terms_agreement.py @@ -12,6 +12,7 @@ @dataclass class TermsAgreementInput: business_email: Optional[str] = None + name: Optional[str] = None terms_agreement: bool = False marketing_consent: bool = False customer_intent: Optional[str] = None @@ -20,7 +21,7 @@ class TermsAgreementInput: class SaveTermsAgreementInteractor(BaseInteractor): requires_service = False - def validate(self, input: TermsAgreementInput) -> None: + def validate_deprecated(self, input: TermsAgreementInput) -> None: valid_customer_intents = ["Business", "BUSINESS", "Personal", "PERSONAL"] if ( input.customer_intent @@ -30,7 +31,11 @@ def validate(self, input: TermsAgreementInput) -> None: if not self.current_user.is_authenticated: raise Unauthenticated() - def update_terms_agreement(self, input: TermsAgreementInput) -> None: + def validate(self, input: TermsAgreementInput) -> None: + if not self.current_user.is_authenticated: + raise Unauthenticated() + + def update_terms_agreement_deprecated(self, input: TermsAgreementInput) -> None: self.current_user.terms_agreement = input.terms_agreement self.current_user.terms_agreement_at = timezone.now() self.current_user.customer_intent = input.customer_intent @@ -44,6 +49,20 @@ def update_terms_agreement(self, input: TermsAgreementInput) -> None: if input.marketing_consent: self.send_data_to_marketo() + def update_terms_agreement(self, input: TermsAgreementInput) -> None: + self.current_user.terms_agreement = input.terms_agreement + self.current_user.terms_agreement_at = timezone.now() + self.current_user.name = input.name + self.current_user.email_opt_in = input.marketing_consent + self.current_user.save() + + if input.business_email and input.business_email != "": + self.current_user.email = input.business_email + self.current_user.save() + + if input.marketing_consent: + self.send_data_to_marketo() + def send_data_to_marketo(self) -> None: event_data = { "email": self.current_user.email, @@ -52,11 +71,22 @@ def send_data_to_marketo(self) -> None: @sync_to_async def execute(self, input: Any) -> None: - typed_input = TermsAgreementInput( - business_email=input.get("business_email"), - terms_agreement=input.get("terms_agreement"), - marketing_consent=input.get("marketing_consent"), - customer_intent=input.get("customer_intent"), - ) - self.validate(typed_input) - return self.update_terms_agreement(typed_input) + if input.get("name"): + typed_input = TermsAgreementInput( + business_email=input.get("business_email"), + terms_agreement=input.get("terms_agreement"), + marketing_consent=input.get("marketing_consent"), + name=input.get("name"), + ) + self.validate(typed_input) + self.update_terms_agreement(typed_input) + # this handles the deprecated inputs + else: + typed_input = TermsAgreementInput( + business_email=input.get("business_email"), + terms_agreement=input.get("terms_agreement"), + marketing_consent=input.get("marketing_consent"), + customer_intent=input.get("customer_intent"), + ) + self.validate_deprecated(typed_input) + self.update_terms_agreement_deprecated(typed_input) diff --git a/codecov_auth/commands/owner/interactors/tests/test_save_terms_agreement.py b/codecov_auth/commands/owner/interactors/tests/test_save_terms_agreement.py index fca8aee6d1..70c754f232 100644 --- a/codecov_auth/commands/owner/interactors/tests/test_save_terms_agreement.py +++ b/codecov_auth/commands/owner/interactors/tests/test_save_terms_agreement.py @@ -32,8 +32,10 @@ def execute(self, current_user, input=None): input=real_input, ) + #### Start of older tests + @freeze_time("2022-01-01T00:00:00") - def test_update_user_when_agreement_is_false(self): + def test_deprecated_update_user_when_agreement_is_false(self): self.execute( current_user=self.current_user, input={"terms_agreement": False, "customer_intent": "Business"}, @@ -47,7 +49,7 @@ def test_update_user_when_agreement_is_false(self): assert self.current_user.email == before_refresh_business_email @freeze_time("2022-01-01T00:00:00") - def test_update_user_when_agreement_is_true(self): + def test_deprecated_update_user_when_agreement_is_true(self): self.execute( current_user=self.current_user, input={"terms_agreement": True, "customer_intent": "Business"}, @@ -61,7 +63,7 @@ def test_update_user_when_agreement_is_true(self): assert self.current_user.email == before_refresh_business_email @freeze_time("2022-01-01T00:00:00") - def test_update_owner_and_user_when_email_is_not_empty(self): + def test_deprecated_update_owner_and_user_when_email_is_not_empty(self): self.execute( current_user=self.current_user, input={ @@ -77,14 +79,14 @@ def test_update_owner_and_user_when_email_is_not_empty(self): self.current_user.refresh_from_db() assert self.current_user.email == "something@email.com" - def test_validation_error_when_customer_intent_invalid(self): + def test_deprecated_validation_error_when_customer_intent_invalid(self): with pytest.raises(ValidationError): self.execute( current_user=self.current_user, input={"terms_agreement": None, "customer_intent": "invalid"}, ) - def test_user_is_not_authenticated(self): + def test_deprecated_user_is_not_authenticated(self): with pytest.raises(Unauthenticated): self.execute( current_user=AnonymousUser(), @@ -95,7 +97,7 @@ def test_user_is_not_authenticated(self): }, ) - def test_email_opt_in_saved_in_db(self): + def test_deprecated_email_opt_in_saved_in_db(self): self.execute( current_user=self.current_user, input={ @@ -110,7 +112,9 @@ def test_email_opt_in_saved_in_db(self): @patch( "codecov_auth.commands.owner.interactors.save_terms_agreement.SaveTermsAgreementInteractor.send_data_to_marketo" ) - def test_marketo_called_only_with_consent(self, mock_send_data_to_marketo): + def test_deprecated_marketo_called_only_with_consent( + self, mock_send_data_to_marketo + ): self.execute( current_user=self.current_user, input={ @@ -125,7 +129,9 @@ def test_marketo_called_only_with_consent(self, mock_send_data_to_marketo): @patch( "codecov_auth.commands.owner.interactors.save_terms_agreement.SaveTermsAgreementInteractor.send_data_to_marketo" ) - def test_marketo_not_called_without_consent(self, mock_send_data_to_marketo): + def test_deprecated_marketo_not_called_without_consent( + self, mock_send_data_to_marketo + ): self.execute( current_user=self.current_user, input={ @@ -136,3 +142,115 @@ def test_marketo_not_called_without_consent(self, mock_send_data_to_marketo): ) mock_send_data_to_marketo.assert_not_called() + + #### End of older tests + + @freeze_time("2022-01-01T00:00:00") + def test_update_user_when_agreement_is_false(self): + self.execute( + current_user=self.current_user, + input={ + "business_email": "something@email.com", + "name": "codecov-user", + "terms_agreement": False, + }, + ) + before_refresh_business_email = self.current_user.email + + assert self.current_user.terms_agreement == False + assert self.current_user.terms_agreement_at == self.updated_at + + self.current_user.refresh_from_db() + assert self.current_user.email == before_refresh_business_email + + @freeze_time("2022-01-01T00:00:00") + def test_update_user_when_agreement_is_true(self): + self.execute( + current_user=self.current_user, + input={ + "business_email": "something@email.com", + "name": "codecov-user", + "terms_agreement": True, + }, + ) + before_refresh_business_email = self.current_user.email + + assert self.current_user.terms_agreement == True + assert self.current_user.terms_agreement_at == self.updated_at + + self.current_user.refresh_from_db() + assert self.current_user.email == before_refresh_business_email + + @freeze_time("2022-01-01T00:00:00") + def test_update_owner_and_user_when_email_and_name_are_not_empty(self): + self.execute( + current_user=self.current_user, + input={ + "business_email": "something@email.com", + "name": "codecov-user", + "terms_agreement": True, + }, + ) + + assert self.current_user.terms_agreement == True + assert self.current_user.terms_agreement_at == self.updated_at + + self.current_user.refresh_from_db() + assert self.current_user.email == "something@email.com" + assert self.current_user.name == "codecov-user" + + def test_user_is_not_authenticated(self): + with pytest.raises(Unauthenticated): + self.execute( + current_user=AnonymousUser(), + input={ + "business_email": "something@email.com", + "name": "codecov-user", + "terms_agreement": True, + }, + ) + + def test_email_opt_in_saved_in_db(self): + self.execute( + current_user=self.current_user, + input={ + "business_email": "something@email.com", + "name": "codecov-user", + "terms_agreement": True, + "marketing_consent": True, + }, + ) + self.current_user.refresh_from_db() + assert self.current_user.email_opt_in == True + + @patch( + "codecov_auth.commands.owner.interactors.save_terms_agreement.SaveTermsAgreementInteractor.send_data_to_marketo" + ) + def test_marketo_called_only_with_consent(self, mock_send_data_to_marketo): + self.execute( + current_user=self.current_user, + input={ + "business_email": "something@email.com", + "name": "codecov-user", + "terms_agreement": True, + "marketing_consent": True, + }, + ) + + mock_send_data_to_marketo.assert_called_once() + + @patch( + "codecov_auth.commands.owner.interactors.save_terms_agreement.SaveTermsAgreementInteractor.send_data_to_marketo" + ) + def test_marketo_not_called_without_consent(self, mock_send_data_to_marketo): + self.execute( + current_user=self.current_user, + input={ + "business_email": "something@email.com", + "name": "codecov-user", + "terms_agreement": True, + "marketing_consent": False, + }, + ) + + mock_send_data_to_marketo.assert_not_called() diff --git a/core/apps.py b/core/apps.py index db32d6e931..c23030afc7 100644 --- a/core/apps.py +++ b/core/apps.py @@ -1,10 +1,15 @@ +import logging + from django.apps import AppConfig +from django.core.management import call_command from shared.helpers.cache import RedisBackend from services.redis_configuration import get_redis_connection from utils.cache import cache from utils.config import RUN_ENV +logger = logging.getLogger(__name__) + class CoreConfig(AppConfig): name = "core" @@ -12,6 +17,24 @@ class CoreConfig(AppConfig): def ready(self): import core.signals # noqa: F401 + if RUN_ENV == "DEV": + try: + # Call your management command here + call_command( + "insert_data_to_db_from_csv", + "core/management/commands/codecovTiers-Jan25.csv", + "--model", + "tiers", + ) + call_command( + "insert_data_to_db_from_csv", + "core/management/commands/codecovPlans-Jan25.csv", + "--model", + "plans", + ) + except Exception as e: + logger.error(f"Failed to run startup command: {e}") + if RUN_ENV not in ["DEV", "TESTING"]: cache_backend = RedisBackend(get_redis_connection()) cache.configure(cache_backend) diff --git a/core/management/commands/check_for_migration_conflicts.py b/core/management/commands/check_for_migration_conflicts.py index 85b965c678..29e77ac14f 100644 --- a/core/management/commands/check_for_migration_conflicts.py +++ b/core/management/commands/check_for_migration_conflicts.py @@ -22,12 +22,11 @@ def handle(self, *args, **options): for prefix, grouped_migrations in migrations_by_prefix.items(): if len(grouped_migrations) > 1: conflicts_found = True - print( + print( # noqa: T201 f"Conflict found in migrations for {app.name} with prefix {prefix}:" ) for grouped_migration in grouped_migrations: - print(grouped_migration) - print() + print(grouped_migration) # noqa: T201 # It's expected to not find migration folders for Django/3rd party apps except FileNotFoundError: pass @@ -35,4 +34,4 @@ def handle(self, *args, **options): if conflicts_found: raise Exception("Found conflicts in migrations.") else: - print("No conflicts found!") + print("No conflicts found!") # noqa: T201 diff --git a/core/management/commands/codecovPlans-Jan25.csv b/core/management/commands/codecovPlans-Jan25.csv new file mode 100644 index 0000000000..083550a76f --- /dev/null +++ b/core/management/commands/codecovPlans-Jan25.csv @@ -0,0 +1,14 @@ +"id","created_at","updated_at","base_unit_price","benefits","billing_rate","is_active","marketing_name","max_seats","monthly_uploads_limit","paid_plan","name","tier_id","stripe_id" +10,2025-01-16 04:40:55.162 -0800,2025-01-24 11:33:46.043 -0800,0,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories""}",,true,Developer,,,false,users-trial,6, +9,2025-01-16 04:39:59.759 -0800,2025-01-24 11:34:10.038 -0800,10,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",annually,true,Enterprise Cloud,,,true,users-enterprisey,4,price_1LmjzwGlVGuVgOrkIwlM46EU +8,2025-01-16 04:39:15.877 -0800,2025-01-24 11:34:31.904 -0800,12,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",monthly,true,Enterprise Cloud,,,true,users-enterprisem,4,price_1LmjypGlVGuVgOrkzKtNqhwW +7,2025-01-16 04:38:12.544 -0800,2025-01-24 11:34:53.935 -0800,4,"{""Up to 10 users"",""Unlimited repositories"",""2500 private repo uploads"",""Patch coverage analysis""}",annually,true,Team,10,2500,true,users-teamy,2,price_1NrlXiGlVGuVgOrkgMTw5yno +6,2025-01-16 04:37:08.918 -0800,2025-01-24 11:35:15.346 -0800,5,"{""Up to 10 users"",""Unlimited repositories"",""2500 private repo uploads"",""Patch coverage analysis""}",monthly,true,Team,10,2500,true,users-teamm,2,price_1NqPKdGlVGuVgOrkm9OFvtz8 +5,2025-01-16 04:35:34.152 -0800,2025-01-24 11:35:42.724 -0800,10,"{""Includes 5 seats"",""$10 per additional seat"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",annually,true,Sentry Pro,5,,true,users-sentryy,5,price_1MlYAYGlVGuVgOrke9SdbBUn +4,2025-01-16 04:34:33.867 -0800,2025-01-24 11:35:48.218 -0800,12,"{""Includes 5 seats"",""$10 per additional seat"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",monthly,true,Sentry Pro,5,,true,users-sentrym,5,price_1MlY9yGlVGuVgOrkHluurBtJ +3,2025-01-16 04:32:44.655 -0800,2025-01-24 11:36:09.660 -0800,10,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",annually,true,Pro,,,true,users-pr-inappy,3,price_1Gv2COGlVGuVgOrkuOYVLIj7 +2,2025-01-16 04:30:42.897 -0800,2025-01-24 11:36:14.651 -0800,12,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories"",""Priority Support""}",monthly,true,Pro,,,true,users-pr-inappm,3,price_1Gv2B8GlVGuVgOrkFnLunCgc +13,2025-01-23 14:25:04.793 -0800,2025-01-23 14:25:04.793 -0800,12,"{""Configurable # of users"",""Unlimited public repositories"",""Unlimited private repositories""}",,true,Github Marketplace,,,false,users,3, +12,2025-01-16 04:44:51.064 -0800,2025-01-24 11:33:14.405 -0800,0,"{""Up to 1 user"",""Unlimited public repositories"",""Unlimited private repositories""}",,true,Developer,1,250,false,users-developer,2, +11,2025-01-16 04:44:01.249 -0800,2025-01-24 11:33:28.532 -0800,0,"{""Up to 1 user"",""Unlimited public repositories"",""Unlimited private repositories""}",,true,Developer,1,250,false,users-basic,1, +1,2025-01-16 04:26:44.776 -0800,2025-01-24 11:36:32.387 -0800,0,"{""Up to 1 user"",""Unlimited public repositories"",""Unlimited private repositories""}",,false,Developer,1,,false,users-free,1, diff --git a/core/management/commands/codecovTiers-Jan25.csv b/core/management/commands/codecovTiers-Jan25.csv new file mode 100644 index 0000000000..a72a03924b --- /dev/null +++ b/core/management/commands/codecovTiers-Jan25.csv @@ -0,0 +1,7 @@ +"id","created_at","updated_at","tier_name","bundle_analysis","test_analytics","flaky_test_detection","project_coverage","private_repo_support" +2,2025-01-16 04:22:06.838 -0800,2025-01-16 04:22:06.838 -0800,team,true,true,false,false,true +3,2025-01-16 04:22:47.221 -0800,2025-01-16 04:22:47.221 -0800,pro,true,true,true,true,true +4,2025-01-16 04:22:59.652 -0800,2025-01-16 04:22:59.652 -0800,enterprise,true,true,true,true,true +5,2025-01-16 04:23:08.585 -0800,2025-01-16 04:23:08.585 -0800,sentry,true,true,true,true,true +1,2025-01-16 04:21:40.374 -0800,2025-01-16 07:34:49.139 -0800,basic,true,true,false,true,true +6,2025-01-23 14:23:21.504 -0800,2025-01-23 14:23:21.504 -0800,trial,true,true,true,true,true diff --git a/core/management/commands/delete_rate_limit_keys.py b/core/management/commands/delete_rate_limit_keys.py index 075a33862e..03389a0016 100644 --- a/core/management/commands/delete_rate_limit_keys.py +++ b/core/management/commands/delete_rate_limit_keys.py @@ -21,8 +21,8 @@ def handle(self, *args, **options): for key in redis.scan_iter(path): # -1 means the key has no expiry if redis.ttl(key) == -1: - print(f"Deleting key: {key.decode('utf-8')}") + print(f"Deleting key: {key.decode('utf-8')}") # noqa: T201 redis.delete(key) except Exception as e: - print("Error occurred when deleting redis keys") - print(e) + print("Error occurred when deleting redis keys") # noqa: T201 + print(e) # noqa: T201 diff --git a/core/management/commands/insert_data_to_db_from_csv.py b/core/management/commands/insert_data_to_db_from_csv.py new file mode 100644 index 0000000000..cc88388d0e --- /dev/null +++ b/core/management/commands/insert_data_to_db_from_csv.py @@ -0,0 +1,71 @@ +import csv + +from django.core.management.base import BaseCommand +from shared.django_apps.codecov_auth.models import Plan, Tier + + +class Command(BaseCommand): + help = "Insert data from a CSV file into the database for either plans or tiers" + + def add_arguments(self, parser): + parser.add_argument("csv_file", type=str, help="The path to the CSV file") + parser.add_argument( + "--model", + type=str, + choices=["plans", "tiers"], + required=True, + help="Specify the model to insert data into: plans or tiers", + ) + + def handle(self, *args, **kwargs): + csv_file_path = kwargs["csv_file"] + model_choice = kwargs["model"] + + # Determine which model to use + if model_choice == "plans": + Model = Plan + elif model_choice == "tiers": + Model = Tier + else: + self.stdout.write(self.style.ERROR("Invalid model choice")) + return + + with open(csv_file_path, newline="") as csvfile: + reader = csv.DictReader(csvfile) + for row in reader: + model_data = { + field: self.convert_value(value) + for field, value in row.items() + if field in [f.name for f in Model._meta.fields] + } + + # Handle ForeignKey for tier + if "tier_id" in row and model_choice == "plans": + try: + model_data["tier"] = Tier.objects.get(id=row["tier_id"]) + except Tier.DoesNotExist: + self.stdout.write( + self.style.ERROR( + f"Tier with id {row['tier_id']} does not exist. Skipping row." + ) + ) + continue + + Model.objects.create(**model_data) + self.stdout.write(self.style.SUCCESS(f"Inserted row: {row}")) + + self.stdout.write( + self.style.SUCCESS( + f"Successfully inserted all data into {model_choice} from CSV" + ) + ) + + def convert_value(self, value): + """Convert CSV string values to appropriate Python types.""" + if value == "": + return None + if value.lower() == "true": + return True + if value.lower() == "false": + return False + return value diff --git a/core/management/commands/update_gitlab_webhooks.py b/core/management/commands/update_gitlab_webhooks.py index 74697aaf1b..c97a919546 100644 --- a/core/management/commands/update_gitlab_webhooks.py +++ b/core/management/commands/update_gitlab_webhooks.py @@ -31,11 +31,8 @@ def handle(self, *args, **options): ) for repo in repos: - print("repoid:", repo.pk) - user = get_bot_user(repo) if user is None: - print("no bot user") continue webhook_secret = str(uuid.uuid4()) @@ -63,7 +60,7 @@ def handle(self, *args, **options): repo.webhook_secret = webhook_secret repo.save() except TorngitClientError as e: - print("error making GitLab API call") - print(e) + print("error making GitLab API call") # noqa: T201 + print(e) # noqa: T201 except TorngitRefreshTokenFailedError: - print("refresh token failed") + print("refresh token failed") # noqa: T201 diff --git a/core/tests/test_management_commands.py b/core/tests/test_management_commands.py index 0741698556..f433002bfa 100644 --- a/core/tests/test_management_commands.py +++ b/core/tests/test_management_commands.py @@ -1,9 +1,13 @@ +import csv +import os +import tempfile import unittest.mock as mock from io import StringIO import pytest from django.core.management import call_command from shared.config import ConfigHelper +from shared.django_apps.codecov_auth.models import Plan, Tier from shared.django_apps.core.tests.factories import OwnerFactory, RepositoryFactory from services.redis_configuration import get_redis_connection @@ -121,3 +125,46 @@ def test_delete_rate_limit_keys_ip_option(): # Get rid of lingering keys redis.delete("rl-user:1") redis.delete("rl-ip:2") + + +@pytest.mark.django_db +def test_insert_data_to_db_from_csv_for_plans_and_tiers(): + with tempfile.NamedTemporaryFile(mode="w", delete=False, newline="") as temp_csv: + writer = csv.writer(temp_csv) + writer.writerow(["id", "tier_name"]) + writer.writerow([1, "Tier 1"]) + writer.writerow([2, "Tier 2"]) + csv_path = temp_csv.name + + out = StringIO() + call_command("insert_data_to_db_from_csv", csv_path, "--model", "tiers", stdout=out) + + # Check the output + assert "Successfully inserted all data into tiers from CSV" in out.getvalue() + + # Verify the data was inserted correctly + assert Tier.objects.filter(tier_name="Tier 1").exists() + assert Tier.objects.filter(tier_name="Tier 2").exists() + + # Create a temporary CSV file + with tempfile.NamedTemporaryFile(mode="w", delete=False, newline="") as temp_csv: + writer = csv.writer(temp_csv) + writer.writerow( + ["name", "marketing_name", "base_unit_price", "tier_id", "is_active"] + ) + writer.writerow(["Plan A", "Marketing A", 100, 1, "true"]) + writer.writerow(["Plan B", "Marketing B", 200, 2, "false"]) + csv_path = temp_csv.name + + out = StringIO() + call_command("insert_data_to_db_from_csv", csv_path, "--model", "plans", stdout=out) + + # Check the output + assert "Successfully inserted all data into plans from CSV" in out.getvalue() + + # Verify the data was inserted correctly + assert Plan.objects.filter(name="Plan A").exists() + assert Plan.objects.filter(name="Plan B").exists() + + # Clean up the temporary file + os.remove(csv_path) diff --git a/graphql_api/tests/mutation/test_save_terms_agreement.py b/graphql_api/tests/mutation/test_save_terms_agreement.py index 9267be5d0e..a647e37b8a 100644 --- a/graphql_api/tests/mutation/test_save_terms_agreement.py +++ b/graphql_api/tests/mutation/test_save_terms_agreement.py @@ -18,22 +18,46 @@ class SaveTermsAgreementMutationTest(GraphQLTestHelper, TransactionTestCase): - def _request(self, owner=None): + def _request_deprecated(self, owner=None): return self.gql_request( query, variables={"input": {"termsAgreement": True, "customerIntent": "Business"}}, owner=owner, ) - def _request_invalid_customer_intent(self, owner=None): + def _request_invalid_customer_intent_deprecated(self, owner=None): return self.gql_request( query, variables={"input": {"termsAgreement": True, "customerIntent": "invalid"}}, owner=owner, ) - def test_unauthenticated(self): - assert self._request() == { + def _request(self, owner=None): + return self.gql_request( + query, + variables={ + "input": { + "termsAgreement": True, + "businessEmail": "something@email.com", + "name": "codecov-user", + } + }, + owner=owner, + ) + + def test_invalid_customer_intent_deprecated(self): + owner = OwnerFactory() + assert self._request_invalid_customer_intent_deprecated(owner=owner) == { + "saveTermsAgreement": { + "error": { + "__typename": "ValidationError", + "message": "Invalid customer intent provided", + } + } + } + + def test_unauthenticated_deprecated(self): + assert self._request_deprecated() == { "saveTermsAgreement": { "error": { "__typename": "UnauthenticatedError", @@ -42,17 +66,20 @@ def test_unauthenticated(self): } } - def test_authenticated(self): + def test_authenticated_deprecated(self): owner = OwnerFactory() - assert self._request(owner=owner) == {"saveTermsAgreement": None} + assert self._request_deprecated(owner=owner) == {"saveTermsAgreement": None} - def test_invalid_customer_intent(self): - owner = OwnerFactory() - assert self._request_invalid_customer_intent(owner=owner) == { + def test_unauthenticated(self): + assert self._request() == { "saveTermsAgreement": { "error": { - "__typename": "ValidationError", - "message": "Invalid customer intent provided", + "__typename": "UnauthenticatedError", + "message": "You are not authenticated", } } } + + def test_authenticated(self): + owner = OwnerFactory() + assert self._request(owner=owner) == {"saveTermsAgreement": None} diff --git a/graphql_api/tests/test_plan.py b/graphql_api/tests/test_plan.py index ac136a521d..371b50ea65 100644 --- a/graphql_api/tests/test_plan.py +++ b/graphql_api/tests/test_plan.py @@ -210,7 +210,6 @@ def test_plan_user_count_for_enterprise_org(self, mocked_license): } """ % (enterprise_org.username) data = self.gql_request(query, owner=enterprise_org) - print(data, "look here 1") assert data["owner"]["plan"]["planUserCount"] == 5 assert data["owner"]["plan"]["hasSeatsLeft"] == False diff --git a/graphql_api/types/mutation/save_terms_agreement/save_terms_agreement.graphql b/graphql_api/types/mutation/save_terms_agreement/save_terms_agreement.graphql index cd7caf3197..b3fdf5027e 100644 --- a/graphql_api/types/mutation/save_terms_agreement/save_terms_agreement.graphql +++ b/graphql_api/types/mutation/save_terms_agreement/save_terms_agreement.graphql @@ -8,5 +8,6 @@ input SaveTermsAgreementInput { businessEmail: String termsAgreement: Boolean! marketingConsent: Boolean + name: String customerIntent: String } diff --git a/graphql_api/types/user/user.graphql b/graphql_api/types/user/user.graphql index a278a1946e..acfbab5157 100644 --- a/graphql_api/types/user/user.graphql +++ b/graphql_api/types/user/user.graphql @@ -5,5 +5,6 @@ type User { student: Boolean! studentCreatedAt: DateTime studentUpdatedAt: DateTime + # this will no longer be updated from the UI with Appless customerIntent: String } diff --git a/graphql_api/types/user/user.py b/graphql_api/types/user/user.py index 635afa0a68..aee121b032 100644 --- a/graphql_api/types/user/user.py +++ b/graphql_api/types/user/user.py @@ -2,6 +2,7 @@ from typing import Optional from ariadne import ObjectType +from graphql import GraphQLResolveInfo from codecov_auth.models import Owner from graphql_api.helpers.ariadne import ariadne_load_local_graphql @@ -12,37 +13,42 @@ @user_bindable.field("username") -def resolve_username(user: Owner, info) -> str: +def resolve_username(user: Owner, info: GraphQLResolveInfo) -> str: return user.username @user_bindable.field("name") -def resolve_name(user: Owner, info) -> Optional[str]: +def resolve_name(user: Owner, info: GraphQLResolveInfo) -> Optional[str]: return user.name @user_bindable.field("avatarUrl") -def resolve_avatar_url(user: Owner, info) -> str: +def resolve_avatar_url(user: Owner, info: GraphQLResolveInfo) -> str: return user.avatar_url @user_bindable.field("student") -def resolve_student(user: Owner, info) -> bool: +def resolve_student(user: Owner, info: GraphQLResolveInfo) -> bool: return user.student @user_bindable.field("studentCreatedAt") -def resolve_student_created_at(user: Owner, info) -> Optional[datetime]: +def resolve_student_created_at( + user: Owner, info: GraphQLResolveInfo +) -> Optional[datetime]: return user.student_created_at @user_bindable.field("studentUpdatedAt") -def resolve_student_updated_at(user: Owner, info) -> Optional[datetime]: +def resolve_student_updated_at( + user: Owner, info: GraphQLResolveInfo +) -> Optional[datetime]: return user.student_updated_at +# this will no longer be updated from the UI @user_bindable.field("customerIntent") -def resolve_customer_intent(user: Owner, info) -> str: +def resolve_customer_intent(user: Owner, info: GraphQLResolveInfo) -> Optional[str]: owner = user if not owner.user: return None diff --git a/open_telemetry.py b/open_telemetry.py index 1e12ba4c46..14d674d562 100644 --- a/open_telemetry.py +++ b/open_telemetry.py @@ -141,15 +141,15 @@ def export(self, spans): logging.exception("failed to export all spans") return SpanExportResult.FAILURE except requests.HTTPError as e: - print(e) - logging.exception("HTTP server returned erroneous response") + logging.exception( + "HTTP server returned erroneous response", extra=dict(error=e) + ) return SpanExportResult.FAILURE except requests.Timeout: logging.exception("request timed out") return SpanExportResult.FAILURE except Exception as e: - print(e) - logging.exception("request failed") + logging.exception("request failed", extra=dict(error=e)) return SpanExportResult.FAILURE return SpanExportResult.SUCCESS diff --git a/ruff.toml b/ruff.toml index 00a1200de5..7bea1227e6 100644 --- a/ruff.toml +++ b/ruff.toml @@ -46,6 +46,7 @@ select = [ "PERF", # perflint - performance anti-pattern rules "PLC", # pylint - convention rules "PLE", # pylint - error rules + "T20", # flake8-print - print statements "W", # pycodestyle - warning rules ] ignore = ["F405", "F403", "E501", "E712", "C408"]