diff --git a/.github/workflows/cd-dev.yaml b/.github/workflows/cd-dev.yaml index 61b23e1f..26e8e07c 100644 --- a/.github/workflows/cd-dev.yaml +++ b/.github/workflows/cd-dev.yaml @@ -97,6 +97,10 @@ jobs: value: "https://development.basedosdados.org" - name: "BASE_URL_API" value: "https://development.api.basedosdados.org" + - name: "GOOGLE_DIRECTORY_SUBJECT" + value: "${{ secrets.GOOGLE_DIRECTORY_SUBJECT }}" + - name: "GOOGLE_DIRECTORY_GROUP_KEY" + value: "${{ secrets.GOOGLE_DIRECTORY_GROUP_KEY }}" - name: "REDIS_DB" value: "0" - name: "STRIPE_LIVE_MODE" diff --git a/.github/workflows/cd-prod.yaml b/.github/workflows/cd-prod.yaml index df43bc56..2dab8cea 100644 --- a/.github/workflows/cd-prod.yaml +++ b/.github/workflows/cd-prod.yaml @@ -71,6 +71,10 @@ jobs: value: "https://basedosdados.org" - name: "BASE_URL_API" value: "https://api.basedosdados.org" + - name: "GOOGLE_DIRECTORY_SUBJECT" + value: "${{ secrets.GOOGLE_DIRECTORY_SUBJECT }}" + - name: "GOOGLE_DIRECTORY_GROUP_KEY" + value: "${{ secrets.GOOGLE_DIRECTORY_GROUP_KEY }}" - name: "REDIS_DB" value: "0" - name: "STRIPE_LIVE_MODE" diff --git a/.github/workflows/cd-staging.yaml b/.github/workflows/cd-staging.yaml index 031b7a54..4fdc111a 100644 --- a/.github/workflows/cd-staging.yaml +++ b/.github/workflows/cd-staging.yaml @@ -71,6 +71,10 @@ jobs: value: "https://staging.basedosdados.org" - name: "BASE_URL_API" value: "https://staging.api.basedosdados.org" + - name: "GOOGLE_DIRECTORY_SUBJECT" + value: "${{ secrets.GOOGLE_DIRECTORY_SUBJECT }}" + - name: "GOOGLE_DIRECTORY_GROUP_KEY" + value: "${{ secrets.GOOGLE_DIRECTORY_GROUP_KEY }}" - name: "REDIS_DB" value: "0" - name: "STRIPE_LIVE_MODE" diff --git a/bd_api/apps/account/migrations/0011_subscription.py b/bd_api/apps/account/migrations/0011_subscription.py new file mode 100644 index 00000000..03bf53c2 --- /dev/null +++ b/bd_api/apps/account/migrations/0011_subscription.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by Django 4.2.6 on 2023-11-12 19:43 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("account", "0010_alter_account_is_admin"), + ] + + operations = [ + migrations.CreateModel( + name="Subscription", + fields=[ + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ( + "is_active", + models.BooleanField( + default=False, + help_text="Indica se a inscrição está ativa", + verbose_name="Ativo", + ), + ), + ( + "admin", + models.OneToOneField( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="admin_subscription", + to=settings.AUTH_USER_MODEL, + ), + ), + ("subscribers", models.ManyToManyField(to=settings.AUTH_USER_MODEL)), + ], + options={ + "verbose_name": "Subscription", + "verbose_name_plural": "Subscriptions", + }, + ), + ] diff --git a/bd_api/apps/account/models.py b/bd_api/apps/account/models.py index 0b627efe..202766c5 100644 --- a/bd_api/apps/account/models.py +++ b/bd_api/apps/account/models.py @@ -367,3 +367,26 @@ def get_team(self): return self.team get_team.short_description = "Equipe" + + +class Subscription(BdmModel): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + id = models.UUIDField(primary_key=True, default=uuid4) + is_active = models.BooleanField( + "Ativo", + default=False, + help_text="Indica se a inscrição está ativa", + ) + + admin = models.OneToOneField( + "Account", + on_delete=models.DO_NOTHING, + related_name="admin_subscription", + ) + subscribers = models.ManyToManyField(Account) + + class Meta: + verbose_name = "Subscription" + verbose_name_plural = "Subscriptions" diff --git a/bd_api/apps/payment/apps.py b/bd_api/apps/payment/apps.py index 7d687830..67be1a48 100644 --- a/bd_api/apps/payment/apps.py +++ b/bd_api/apps/payment/apps.py @@ -8,3 +8,4 @@ class PaymentsConfig(DjstripeAppConfig): def ready(self): super().ready() import bd_api.apps.payment.signals # noqa + import bd_api.apps.payment.webhooks # noqa diff --git a/bd_api/apps/payment/graphql.py b/bd_api/apps/payment/graphql.py index c3e2ce23..7880d2f5 100644 --- a/bd_api/apps/payment/graphql.py +++ b/bd_api/apps/payment/graphql.py @@ -2,14 +2,15 @@ from djstripe.models import Customer as DJStripeCustomer from djstripe.models import Price as DJStripePrice from djstripe.models import Subscription as DJStripeSubscription -from graphene import ID, Field, Float, InputObjectType, List, Mutation, ObjectType, String +from graphene import ID, Boolean, Field, Float, InputObjectType, List, Mutation, ObjectType, String from graphene_django import DjangoObjectType from graphene_django.filter import DjangoFilterConnectionField from graphql_jwt.decorators import login_required from loguru import logger from stripe import Customer as StripeCustomer -from bd_api.apps.account.models import Account +from bd_api.apps.account.models import Account, Subscription +from bd_api.apps.payment.webhooks import add_user, remove_user from bd_api.custom.graphql_base import CountableConnection, PlainTextNode @@ -146,7 +147,7 @@ def mutate(cls, root, info, input): return cls(customer=customer) except Exception as e: logger.error(e) - return cls(errors=str(e)) + return cls(errors=[str(e)]) class StripeCustomerMutation(ObjectType): @@ -189,6 +190,7 @@ def mutate(cls, root, info, price_id): payment_behaviour="default_incomplete", payment_settings={"save_default_payment_method": "on_subscription"}, ) + # Criar inscrição interna return cls(subscription=subscription) except Exception as e: logger.error(e) @@ -221,5 +223,65 @@ class StripeSubscriptionMutation(ObjectType): delete_stripe_subscription = StripeSubscriptionDeleteMutation.Field() +class StripeSubscriptionCustomerInput(InputObjectType): + account_id = ID(required=True) + subscription_id = ID(required=True) + + +class StripeSubscriptionCustomerCreateMutation(Mutation): + """Add account to subscription""" + + ok = Boolean() + errors = List(String) + + class Arguments: + input = StripeSubscriptionCustomerInput() + + @classmethod + @login_required + def mutate(cls, root, info, input): + try: + admin = info.context.user + admin = Account.objects.get(id=admin.id) + account = Account.objects.get(id=input.account_id).first() + subscription = Subscription.objects.get(id=input.subscription_id).first() + assert admin.id == subscription.admin.id + add_user(account.email) + return cls(ok=True) + except Exception as e: + logger.error(e) + return cls(errors=[str(e)]) + + +class StripeSubscriptionCustomerDeleteMutation(Mutation): + """Remove account from subscription""" + + ok = Boolean() + errors = List(String) + + class Arguments: + input = StripeSubscriptionCustomerInput() + + @classmethod + @login_required + def mutate(cls, root, info, input): + try: + admin = info.context.user + admin = Account.objects.get(id=admin.id) + account = Account.objects.get(id=input.account_id).first() + subscription = Subscription.objects.get(id=input.subscription_id).first() + assert admin.id == subscription.admin.id + remove_user(account.email) + return cls(ok=True) + except Exception as e: + logger.error(e) + return cls(errors=[str(e)]) + + +class StripeSubscriptionCustomerMutation(ObjectType): + create_stripe_customer_subscription = StripeSubscriptionCustomerCreateMutation.Field() + update_stripe_customer_subscription = StripeSubscriptionCustomerDeleteMutation.Field() + + # Reference # https://stripe.com/docs/billing/subscriptions/build-subscriptions?ui=elementsf diff --git a/bd_api/apps/payment/views.py b/bd_api/apps/payment/views.py index 03f49ad6..e69de29b 100644 --- a/bd_api/apps/payment/views.py +++ b/bd_api/apps/payment/views.py @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -from django.contrib.auth.decorators import login_required -from django.http import HttpRequest -from django.utils.decorators import method_decorator -from django.views import View -from django.views.decorators.csrf import csrf_exempt - - -@method_decorator(csrf_exempt, "dispatch") -@method_decorator(login_required, "dispatch") -class StripeCustomerSubscriptionView(View): - def post(self, request: HttpRequest, account_id: str, subscription_id: str): - """Add customer to a stripe subscription""" - ... - - def delete(self, request: HttpRequest, account_id: str, subscription_id: str): - """Remove customer from a stripe subscription""" - ... - - -# Reference -# https://stripe.com/docs/billing/subscriptions/build-subscriptions?ui=elementsf diff --git a/bd_api/apps/payment/webhooks.py b/bd_api/apps/payment/webhooks.py new file mode 100644 index 00000000..815b0949 --- /dev/null +++ b/bd_api/apps/payment/webhooks.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from django.conf import settings +from djstripe import webhooks +from djstripe.models import Event +from google.oauth2.service_account import Credentials +from googleapiclient.discovery import Resource, build +from googleapiclient.errors import HttpError +from loguru import logger + +from bd_api.apps.account.models import Account, Subscription + +logger = logger.bind(codename="payment_webhook") + + +def get_credentials(scopes: list[str] = None, impersonate: str = None): + """Get google credentials with scope or subject""" + cred = Credentials.from_service_account_file( + settings.GOOGLE_APPLICATION_CREDENTIALS, + ) + if scopes: + cred = cred.with_scopes(scopes) + if impersonate: + cred = cred.with_subject(impersonate) + return cred + + +def get_service() -> Resource: + """Get google directory service""" + credentials = get_credentials( + settings.GOOGLE_DIRECTORY_SCOPES, + settings.GOOGLE_DIRECTORY_SUBJECT, + ) + return build("admin", "directory_v1", credentials=credentials) + + +def add_user( + email: str, + role: str = "MEMBER", + group_key: str = settings.GOOGLE_DIRECTORY_GROUP_KEY, +): + """Add user to google group""" + try: + service = get_service() + service.members().insert( + groupKey=group_key, + body={"email": email, "role": role}, + ).execute() + except HttpError as e: + if e.resp.status == 490: + logger.warning(f"{email} already exists") + else: + logger.error(e) + raise e + + +def remove_user( + email: str, + group_key: str = settings.GOOGLE_DIRECTORY_GROUP_KEY, +) -> None: + """Remove user from google group""" + try: + service = get_service() + service.members().delete( + groupKey=group_key, + memberKey=email, + ).execute() + except Exception as e: + logger.error(e) + raise e + + +@webhooks.handler("customer.subscription.created") +def subscribe(event: Event, **kwargs): + """Add customer to allowed google groups""" + add_user(event.customer.email) + admin = Account.objects.get(email=event.customer.email).first() + Subscription.objects.create(admin=admin, is_active=True) + + +@webhooks.handler("customer.subscription.deleted") +def unsubscribe(event: Event, **kwargs): + """Remove customer from allowed google groups""" + remove_user(event.customer.email) + admin = Account.objects.get(email=event.customer.email).first() + admin.subscription.is_active = False + admin.subscription.save() + + +# Reference +# https://developers.google.com/admin-sdk/directory/v1/guides/troubleshoot-error-codes +# https://developers.google.com/admin-sdk/reseller/v1/support/directory_api_common_errors diff --git a/bd_api/apps/schema.py b/bd_api/apps/schema.py index 4804da00..128759bd 100644 --- a/bd_api/apps/schema.py +++ b/bd_api/apps/schema.py @@ -2,6 +2,7 @@ from bd_api.apps.payment.graphql import ( StripeCustomerMutation, StripePriceQuery, + StripeSubscriptionCustomerMutation, StripeSubscriptionMutation, ) from bd_api.custom.graphql_auto import build_schema @@ -14,5 +15,6 @@ extra_mutations=[ StripeCustomerMutation, StripeSubscriptionMutation, + StripeSubscriptionCustomerMutation, ], ) diff --git a/bd_api/settings/local.py b/bd_api/settings/local.py index 78d9354f..ddb2d9a3 100644 --- a/bd_api/settings/local.py +++ b/bd_api/settings/local.py @@ -49,9 +49,17 @@ # Logging setup_logger(level="DEBUG", ignore=["faker"], serialize=False) -# Google Application Credentials +# Google Auth GOOGLE_APPLICATION_CREDENTIALS = getenv("GOOGLE_APPLICATION_CREDENTIALS") +# Google Directory +GOOGLE_DIRECTORY_SCOPES = [ + "https://www.googleapis.com/auth/admin.directory.user", + "https://www.googleapis.com/auth/admin.directory.group", +] +GOOGLE_DIRECTORY_SUBJECT = getenv("GOOGLE_DIRECTORY_SUBJECT") +GOOGLE_DIRECTORY_GROUP_KEY = getenv("GOOGLE_DIRECTORY_GROUP_KEY") + # Google Cloud Storage ... diff --git a/bd_api/settings/remote.py b/bd_api/settings/remote.py index b6de29fd..7fd22e4d 100644 --- a/bd_api/settings/remote.py +++ b/bd_api/settings/remote.py @@ -54,8 +54,16 @@ # Logging setup_logger(level="INFO", ignore=["faker"], serialize=True) -# Google Application Credentials -GOOGLE_APPLICATION_CREDENTIALS = getenvp("GOOGLE_APPLICATION_CREDENTIALS", "") +# Google Auth +GOOGLE_APPLICATION_CREDENTIALS = getenvp("GOOGLE_APPLICATION_CREDENTIALS") + +# Google Directory +GOOGLE_DIRECTORY_SCOPES = [ + "https://www.googleapis.com/auth/admin.directory.user", + "https://www.googleapis.com/auth/admin.directory.group", +] +GOOGLE_DIRECTORY_SUBJECT = getenvp("GOOGLE_DIRECTORY_SUBJECT") +GOOGLE_DIRECTORY_GROUP_KEY = getenvp("GOOGLE_DIRECTORY_GROUP_KEY") # Google Cloud Storage GS_SERVICE_ACCOUNT = getenvp("GCP_SA")