From 278475f2aa508700a9a5af7d809dda28b6ee903a Mon Sep 17 00:00:00 2001 From: renzon Date: Wed, 14 Sep 2022 06:35:52 -0300 Subject: [PATCH] Created command to syncronize subscriptions Part of #4764 --- Pipfile.lock | 18 ++--- pythonpro/__init__.py | 4 +- pythonpro/celery.py | 2 +- pythonpro/memberkit/api.py | 32 +++++++++ pythonpro/memberkit/facade.py | 65 ++++++++++++++++++- .../inactivate_expired_subscriptions.py | 13 ++++ pythonpro/memberkit/models.py | 36 ++++++++++ pythonpro/memberkit/tests/test_commands.py | 8 +++ .../test_process_expired_subscriptions.py | 38 +++++++++++ .../tests/test_subscription_summary.py | 23 +++++++ pythonpro/settings.py | 3 +- 11 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 pythonpro/memberkit/management/commands/inactivate_expired_subscriptions.py create mode 100644 pythonpro/memberkit/tests/test_process_expired_subscriptions.py create mode 100644 pythonpro/memberkit/tests/test_subscription_summary.py diff --git a/Pipfile.lock b/Pipfile.lock index 2f3eb302..6d16779a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -77,19 +77,19 @@ }, "boto3": { "hashes": [ - "sha256:19762b6a1adbe1963e26b8280211ca148017c970a2e1386312a9fc8a0a17dbd5", - "sha256:367a73c1ff04517849d8c4177fd775da2e258a3912ff6a497be258c30f509046" + "sha256:983ec9e539431c29b5265e435b91af7c0d77a75809e173427798edb4ede1d69c", + "sha256:f35a42c6d0130a75e58485efa94383256d9b8c72c3a31ad872807873a8800363" ], "index": "pypi", - "version": "==1.26.97" + "version": "==1.26.98" }, "botocore": { "hashes": [ - "sha256:0df677eb2bef3ba18ac69e007633559b4426df310eee99df9882437b5faf498a", - "sha256:176740221714c0f031c2cd773879df096dbc0f977c63b3e2ed6a956205f02e82" + "sha256:ae906c1feb56063a38ffd2280232fa44d634057825470d3beed274925088cb42", + "sha256:b74283ff71eb4e57edfa5cf6dc36d959b1eec618a2b1e5e781643184857dd1c4" ], "markers": "python_version >= '3.7'", - "version": "==1.29.97" + "version": "==1.29.98" }, "cachecontrol": { "hashes": [ @@ -1370,11 +1370,11 @@ }, "faker": { "hashes": [ - "sha256:2deeee8fed3d1b8ae5f87d172d4569ddc859aab8693f7cd68eddc5d20400563a", - "sha256:e7c058e1f360f245f265625b32d3189d7229398ad80a8b6bac459891745de052" + "sha256:4c98c42984db54be2246d40e6407cd983db7b1511a70eaff64c3f383a51bace6", + "sha256:9bd71833146b844d848791b79720c7806108130c9603c7074123b3f77b4e97a1" ], "index": "pypi", - "version": "==18.3.0" + "version": "==18.3.1" }, "flake8": { "hashes": [ diff --git a/pythonpro/__init__.py b/pythonpro/__init__.py index 30274e9d..81f9bf76 100644 --- a/pythonpro/__init__.py +++ b/pythonpro/__init__.py @@ -1 +1,3 @@ -from .celery import app # noqa +from .celery import app as celery_app # noqa + +__all__ = ('celery_app',) diff --git a/pythonpro/celery.py b/pythonpro/celery.py index aec3c421..a78aaac1 100644 --- a/pythonpro/celery.py +++ b/pythonpro/celery.py @@ -5,5 +5,5 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pythonpro.settings') app = Celery('pythonpro.celery') -app.config_from_object('django.conf:settings') +app.config_from_object('django.conf:settings', namespace='CELERY') app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) diff --git a/pythonpro/memberkit/api.py b/pythonpro/memberkit/api.py index 1a630c97..6b3e7df5 100644 --- a/pythonpro/memberkit/api.py +++ b/pythonpro/memberkit/api.py @@ -72,6 +72,25 @@ def activate_user(full_name: str, email: str, subscription_type_id: int, expires return user_detail(email) +@_configure_api_key +def update_user_subscription(memberkit_user_id: int, subscription_type_id: int, status: str, expires_at: date = None, *, + api_key=_ApiKeyNone): + user_json = user_detail(memberkit_user_id, api_key=api_key) + if expires_at is None: + expires_at = date(2200, 1, 1) + data = { + 'full_name': user_json['full_name'], + 'email': user_json['email'], + 'status': status, + 'blocked': False, + 'membership_level_id': subscription_type_id, + 'unlimited': False, + 'expires_at': expires_at.strftime('%d/%m/%Y'), + } + response = requests.post(f'{_base_url}/api/v1/users?api_key={api_key}', json=data) + return response.json() + + @_configure_api_key def inactivate_user(memberkit_user_id: int, subscription_type_id: int, *, api_key=_ApiKeyNone): @@ -89,6 +108,19 @@ def inactivate_user(memberkit_user_id: int, subscription_type_id: int, *, return response.json() +@_configure_api_key +def delete_user(memberkit_user_id: int, *, api_key=_ApiKeyNone): + response = requests.delete(f'{_base_url}/api/v1/users/{memberkit_user_id}?api_key={api_key}') + response.raise_for_status() + + +@_configure_api_key +def list_users(page=0, *, api_key=_ApiKeyNone): + response = requests.get(f'{_base_url}/api/v1/users?api_key={api_key}&page={page}') + response.raise_for_status() + return response.json() + + @_configure_api_key def generate_token(memberkit_user_id: int, *, api_key=_ApiKeyNone): user_json = user_detail(memberkit_user_id, api_key=api_key) diff --git a/pythonpro/memberkit/facade.py b/pythonpro/memberkit/facade.py index 47ed586b..20319fb3 100644 --- a/pythonpro/memberkit/facade.py +++ b/pythonpro/memberkit/facade.py @@ -1,11 +1,16 @@ from builtins import Exception from datetime import timedelta +from itertools import count +from logging import Logger from typing import List +from celery import shared_task from django.utils import timezone from pythonpro.memberkit import api -from pythonpro.memberkit.models import SubscriptionType, Subscription, YEAR_IN_DAYS +from pythonpro.memberkit.models import SubscriptionType, Subscription, YEAR_IN_DAYS, UserSubscriptionsSummary + +_logger = Logger(__file__) def synchronize_subscription_types() -> List[SubscriptionType]: @@ -57,7 +62,8 @@ def create_new_subscription(payment, observation: str = '') -> Subscription: def activate(subscription, responsible=None, observation=''): user = subscription.subscriber - subscription.activated_at = timezone.now() + if subscription.status == Subscription.Status.INACTIVE or subscription.activated_at is None: + subscription.activated_at = timezone.now() for subscription_type in subscription.subscription_types.all(): expires_at = subscription.activated_at + timedelta(days=subscription_type.days_of_access) if subscription_type.id in IDS_COMUNIDADE_SUBSCRIPTION: @@ -103,6 +109,26 @@ def inactivate(subscription, responsible=None, observation=''): return subscription +def clean_memberkit_users_up(): + total = 0 + for page in count(1): + memberkit_users = api.list_users(page) + if len(memberkit_users) == 0: + break + for memberkit_user in memberkit_users: + total += 1 + memberkit_user_id = int(memberkit_user['id']) + has_active_subscription = Subscription.objects.filter( + memberkit_user_id=memberkit_user_id, status=Subscription.Status.ACTIVE + ).exists() + if not has_active_subscription: + api.delete_user(memberkit_user_id) + print(f'Desativado: {memberkit_user_id} ############################################') + else: + print(f'Ativo: {memberkit_user_id}') + return total + + class InactiveUserException(Exception): pass @@ -133,3 +159,38 @@ def migrate_when_status_active(user): ).exclude(activated_at__isnull=False) for subscription in status_active_but_not_activated: activate(subscription, observation='Migrado automaticamente da plataforma antiga para nova') + + +@shared_task +def process_expired_subscriptions(user_id): + now = timezone.now() + summary = UserSubscriptionsSummary(user_id) + active_subscriptions = list(summary.active_subscriptions()) + for subscription in active_subscriptions: + if subscription.expires_at < now: + subscription.status = Subscription.Status.INACTIVE + subscription.save() + inactive_subscriptions = [s for s in active_subscriptions if s.status == Subscription.Status.INACTIVE] + active_subscriptions = [s for s in active_subscriptions if s.status == Subscription.Status.ACTIVE] + if len(active_subscriptions) == 0: + for memberkit_user_id in summary.memberkit_user_ids(): + _logger.info(f'Deleted memberkit account for user_id: {user_id}') + api.delete_user(memberkit_user_id) + else: + for inactive_subscription in inactive_subscriptions: + _logger.info(f'Inactivated {inactive_subscription.name} for user_id: {user_id}') + inactivate(inactive_subscription, observation='Inativada por processo de inativação') + for active_subscription in active_subscriptions: + for subscription_type in active_subscription.subscription_types.all().only('id'): + _logger.info(f'Activated {active_subscription.name} for user_id: {user_id}') + api.update_user_subscription( + active_subscription.memberkit_user_id, + subscription_type, + 'activate' + ) + + +def inactivate_expired_subscriptions(): + for user_id in UserSubscriptionsSummary.users_with_active_subscriptions().values_list('id', flat=True): + _logger.info(f'Adding task to process subscriptions expiration for user_id: {user_id}') + process_expired_subscriptions.delay(user_id) diff --git a/pythonpro/memberkit/management/commands/inactivate_expired_subscriptions.py b/pythonpro/memberkit/management/commands/inactivate_expired_subscriptions.py new file mode 100644 index 00000000..03b41726 --- /dev/null +++ b/pythonpro/memberkit/management/commands/inactivate_expired_subscriptions.py @@ -0,0 +1,13 @@ +from django.core.management.base import BaseCommand + +from pythonpro.memberkit import facade + + +class Command(BaseCommand): + help = 'Busca todas assinaturas ativas e inativa as que estão expiradas' + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + facade.inactivate_expired_subscriptions() diff --git a/pythonpro/memberkit/models.py b/pythonpro/memberkit/models.py index 04763e91..d5cbcf61 100644 --- a/pythonpro/memberkit/models.py +++ b/pythonpro/memberkit/models.py @@ -96,3 +96,39 @@ def discourse_groups(self): def __str__(self): return f'Assinatura: {self.id} de {self.subscriber}' + + +class UserSubscriptionsSummary: + """ + This class provides summary data about user subscriptions. + This is not a model, but a utility class to handle user subscriptions + """ + + def __init__(self, django_user_or_user_id): + if isinstance(django_user_or_user_id, get_user_model()): + self.user = django_user_or_user_id + self.user_id = django_user_or_user_id.id + else: + self.user = None + self.user_id = django_user_or_user_id + + def has_active_subscriptions(self): + return self.active_subscriptions().exists() + + def active_subscriptions(self): + return Subscription.objects.filter( + subscriber_id=self.user_id, status=Subscription.Status.ACTIVE + ) + + @classmethod + def users_with_active_subscriptions(cls): + """ + Returns query set with user with at least one active subscription + :return: Django Use Query Set + """ + return get_user_model().objects.filter(subscriptions__status=Subscription.Status.ACTIVE).distinct() + + def memberkit_user_ids(self) -> set[int]: + return set(Subscription.objects.filter( + subscriber_id=self.user_id + ).values_list('memberkit_user_id', flat=True)) diff --git a/pythonpro/memberkit/tests/test_commands.py b/pythonpro/memberkit/tests/test_commands.py index e69ee164..87abebd1 100644 --- a/pythonpro/memberkit/tests/test_commands.py +++ b/pythonpro/memberkit/tests/test_commands.py @@ -165,3 +165,11 @@ def test_subscription_creation_another_subscription_type(role, subscription_type assert Subscription.objects.count() == 2, 'New Subscription should be created' management.call_command('create_subscriptions_for_roles') assert Subscription.objects.count() == 2, 'New Subscription should be created only once' + + +def test_inactivate_expired_subscriptions(django_user_model, mocker): + process_expired_subscriptions_mock = mocker.patch('pythonpro.memberkit.facade.process_expired_subscriptions.delay') + active_user = baker.make(django_user_model) + baker.make(Subscription, status=Subscription.Status.ACTIVE, subscriber=active_user) + management.call_command('inactivate_expired_subscriptions') + process_expired_subscriptions_mock.assert_called_once_with(active_user.id) diff --git a/pythonpro/memberkit/tests/test_process_expired_subscriptions.py b/pythonpro/memberkit/tests/test_process_expired_subscriptions.py new file mode 100644 index 00000000..b1512221 --- /dev/null +++ b/pythonpro/memberkit/tests/test_process_expired_subscriptions.py @@ -0,0 +1,38 @@ +import pytest +from django.utils import timezone +from model_bakery import baker + +from pythonpro.memberkit import facade +from pythonpro.memberkit.models import Subscription + + +@pytest.mark.freeze_time('2023-03-22') +def test_process_valid_subscriptions(django_user_model): + active_user = baker.make(django_user_model) + now = timezone.now() + subscription = baker.make( + Subscription, + status=Subscription.Status.ACTIVE, + subscriber=active_user, + activated_at=now, + days_of_access=1 + ) + facade.process_expired_subscriptions(active_user.id) + subscription.refresh_from_db() + assert subscription.status == Subscription.Status.ACTIVE + +# @pytest.mark.freeze_time('2023-03-22') +# def test_process_expired_subscriptions(django_user_model): +# active_user = baker.make(django_user_model) +# now = timezone.now() +# tow_days_ago = now - timedelta(days=2) +# subscription = baker.make( +# Subscription, +# status=Subscription.Status.ACTIVE, +# subscriber=active_user, +# activated_at=tow_days_ago, +# days_of_access=1 +# ) +# facade.process_expired_subscriptions(active_user.id) +# subscription.refresh_from_db() +# assert subscription.status == Subscription.Status.INACTIVE diff --git a/pythonpro/memberkit/tests/test_subscription_summary.py b/pythonpro/memberkit/tests/test_subscription_summary.py new file mode 100644 index 00000000..0aee4103 --- /dev/null +++ b/pythonpro/memberkit/tests/test_subscription_summary.py @@ -0,0 +1,23 @@ +from model_bakery import baker + +from pythonpro.memberkit.models import UserSubscriptionsSummary, Subscription + + +def test_user_summary_with_no_subscriptions(logged_user): + summary = UserSubscriptionsSummary(logged_user) + assert not summary.has_active_subscriptions() + + +def test_user_with_active_subscriptions(django_user_model): + active_users = baker.make(django_user_model, 5) + for active_user in active_users: + baker.make(Subscription, status=Subscription.Status.ACTIVE, subscriber=active_user) + + inactive_users = baker.make(django_user_model, 5) + for inactive_user in inactive_users: + baker.make(Subscription, status=Subscription.Status.INACTIVE, subscriber=inactive_user) + + active_user_ids_from_db = set( + UserSubscriptionsSummary.users_with_active_subscriptions().values_list('id', flat=True) + ) + assert active_user_ids_from_db == set(active_user.id for active_user in active_users) diff --git a/pythonpro/settings.py b/pythonpro/settings.py index fa7bed34..39dce241 100644 --- a/pythonpro/settings.py +++ b/pythonpro/settings.py @@ -320,9 +320,8 @@ # Celery config -BROKER_URL = config('CLOUDAMQP_URL') +CELERY_BROKER_URL = config('CLOUDAMQP_URL') -CELERY_RESULT_BACKEND = f'{REDIS_URL}/0' CELERY_TASK_SERIALIZER = 'json' CELERY_TIMEZONE = TIME_ZONE