diff --git a/readthedocs/notifications/notification.py b/readthedocs/notifications/notification.py index 30a99a79b1b..5a864851787 100644 --- a/readthedocs/notifications/notification.py +++ b/readthedocs/notifications/notification.py @@ -1,20 +1,18 @@ -# -*- coding: utf-8 -*- """Support for templating of notifications.""" import structlog -from readthedocs.core.context_processors import readthedocs_processor - from django.conf import settings from django.db import models from django.http import HttpRequest from django.template import Context, Template from django.template.loader import render_to_string +from readthedocs.core.context_processors import readthedocs_processor + from . import constants from .backends import send_notification - log = structlog.get_logger(__name__) @@ -39,9 +37,9 @@ class Notification: send_email = True extra_tags = '' - def __init__(self, context_object, request, user=None): + def __init__(self, context_object, request=None, user=None): self.object = context_object - self.request = request + self.request = request or HttpRequest() self.user = user if self.user is None: self.user = request.user diff --git a/readthedocs/organizations/querysets.py b/readthedocs/organizations/querysets.py index 5f4ae437a91..73e722550f6 100644 --- a/readthedocs/organizations/querysets.py +++ b/readthedocs/organizations/querysets.py @@ -38,78 +38,6 @@ def created_days_ago(self, days, field='pub_date'): query_filter[field + '__day'] = when.day return self.filter(**query_filter) - def subscription_trialing(self): - """ - Organizations with subscriptions in a trialing state. - - Trialing state is defined by either having a subscription in the - trialing state or by having an organization created in the last 30 days - """ - date_now = timezone.now() - return self.filter( - Q( - ( - Q( - subscription__status='trialing', - ) | Q( - subscription__status__exact='', - ) - ), - subscription__trial_end_date__gt=date_now, - ) | Q( - subscription__isnull=True, - pub_date__gt=date_now - timedelta(days=30), - ), - ) - - def subscription_trial_ending(self): - """ - Organizations with subscriptions in trial ending state. - - If the organization subscription is either explicitly in a trialing - state, or at least has trial end date in the trial ending date range, - consider this organization's subscription trial ending. Also, if the - subscription is null, use the organization creation date to calculate a - trial end date instead. - """ - date_now = timezone.now() - date_next_week = date_now + timedelta(days=7) - - # TODO: this can be refactored to use - # ``self.subscription_trialing`` and add the 7 days filter to - # that response - return self.filter( - Q( - ( - Q( - subscription__status='trialing', - ) | Q( - subscription__status__exact='', - ) - ), - subscription__trial_end_date__lt=date_next_week, - subscription__trial_end_date__gt=date_now, - ) | Q( - subscription__isnull=True, - pub_date__lt=date_next_week - timedelta(days=30), - pub_date__gt=date_now - timedelta(days=30), - ), - ) - - # TODO: once we are settled into the Trial Plan, merge this method with the - # previous one (``subscription_trial_ending``) - def subscription_trial_plan_ending(self): - """Organizations with subscriptions to Trial Plan about to end.""" - date_now = timezone.now() - date_next_week = date_now + timedelta(days=7) - - return self.filter( - subscription__plan__stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE, - subscription__status='trialing', - subscription__trial_end_date__lt=date_next_week, - subscription__trial_end_date__gt=date_now, - ) - # TODO: once we are settled into the Trial Plan, merge this method with the # following one (``subscription_trial_ended``). def subscription_trial_plan_ended(self): diff --git a/readthedocs/rtd_tests/tests/test_notifications.py b/readthedocs/rtd_tests/tests/test_notifications.py index 03ef2525d1a..0bd33ba37f4 100644 --- a/readthedocs/rtd_tests/tests/test_notifications.py +++ b/readthedocs/rtd_tests/tests/test_notifications.py @@ -229,11 +229,10 @@ def setUp(self): def test_context_data(self): context = { - 'object': {'name': 'object name'}, - 'request': None, - 'production_uri': 'https://readthedocs.org', - 'other': {'name': 'other name'}, - + "object": {"name": "object name"}, + "request": mock.ANY, + "production_uri": "https://readthedocs.org", + "other": {"name": "other name"}, # readthedocs_processor context 'DASHBOARD_ANALYTICS_CODE': mock.ANY, 'DO_NOT_TRACK_ENABLED': mock.ANY, diff --git a/readthedocs/subscriptions/apps.py b/readthedocs/subscriptions/apps.py index 0136510fa8d..97c28b7bf05 100644 --- a/readthedocs/subscriptions/apps.py +++ b/readthedocs/subscriptions/apps.py @@ -4,6 +4,9 @@ class SubscriptionsConfig(AppConfig): + + """App configuration.""" + name = 'readthedocs.subscriptions' label = 'subscriptions' @@ -11,3 +14,25 @@ def ready(self): import readthedocs.subscriptions.event_handlers # noqa import readthedocs.subscriptions.signals # noqa import readthedocs.subscriptions.tasks # noqa + + self._add_custom_manager() + + # pylint: disable=no-self-use + def _add_custom_manager(self): + """ + Add a custom manager to the djstripe Subscription model. + + Patching the model directly isn't recommended, + since there may be an additional setup + done by django when adding a manager. + Using django's contribute_to_class is the recommended + way of adding a custom manager to a third party model. + + The new manager will be accessible from ``Subscription.readthedocs``. + """ + from djstripe.models import Subscription + + from readthedocs.subscriptions.querysets import StripeSubscriptionQueryset + + manager = StripeSubscriptionQueryset.as_manager() + manager.contribute_to_class(Subscription, "readthedocs") diff --git a/readthedocs/subscriptions/event_handlers.py b/readthedocs/subscriptions/event_handlers.py index 95f8dd73ed5..b755f50360a 100644 --- a/readthedocs/subscriptions/event_handlers.py +++ b/readthedocs/subscriptions/event_handlers.py @@ -13,6 +13,10 @@ from readthedocs.organizations.models import Organization from readthedocs.payments.utils import cancel_subscription as cancel_stripe_subscription from readthedocs.subscriptions.models import Subscription +from readthedocs.subscriptions.notifications import ( + SubscriptionEndedNotification, + SubscriptionRequiredNotification, +) log = structlog.get_logger(__name__) @@ -92,6 +96,47 @@ def update_subscription(event): ) +@handler("customer.subscription.deleted") +def subscription_canceled(event): + """ + Send a notification to all owners if the subscription has ended. + + We send a different notification if the subscription + that ended was a "trial subscription", + since those are from new users. + """ + stripe_subscription_id = event.data["object"]["id"] + log.bind(stripe_subscription_id=stripe_subscription_id) + stripe_subscription = djstripe.Subscription.objects.filter( + id=stripe_subscription_id + ).first() + if not stripe_subscription: + log.info("Stripe subscription not found.") + return + + organization = stripe_subscription.customer.rtd_organization + if not organization: + log.error("Subscription isn't attached to an organization") + return + + log.bind(organization_slug=organization.slug) + is_trial_subscription = stripe_subscription.items.filter( + price__id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE + ).exists() + notification_class = ( + SubscriptionRequiredNotification + if is_trial_subscription + else SubscriptionEndedNotification + ) + for owner in organization.owners.all(): + notification = notification_class( + context_object=organization, + user=owner, + ) + notification.send() + log.info("Notification sent.", recipient=owner) + + @handler("checkout.session.completed") def checkout_completed(event): """ diff --git a/readthedocs/subscriptions/notifications.py b/readthedocs/subscriptions/notifications.py index d3a86cb42aa..18e77d8add8 100644 --- a/readthedocs/subscriptions/notifications.py +++ b/readthedocs/subscriptions/notifications.py @@ -1,6 +1,8 @@ """Organization level notifications.""" + from django.urls import reverse +from djstripe import models as djstripe from messages_extends.constants import WARNING_PERSISTENT from readthedocs.notifications import Notification, SiteNotification @@ -25,17 +27,13 @@ class TrialEndingNotification(SubscriptionNotificationMixin, Notification): subject = 'Your trial is ending soon' level = REQUIREMENT - @classmethod - def for_organizations(cls): - advanced_plan_subscription_trial_ending = ( - Organization.objects.subscription_trial_ending() - .created_days_ago(days=24) - ) - trial_plan_subscription_trial_ending = ( - Organization.objects.subscription_trial_plan_ending() - .created_days_ago(days=24) + @staticmethod + def for_subscriptions(): + return ( + djstripe.Subscription.readthedocs.trial_ending() + .created_days_ago(24) + .prefetch_related("customer__rtd_organization") ) - return advanced_plan_subscription_trial_ending | trial_plan_subscription_trial_ending class SubscriptionRequiredNotification(SubscriptionNotificationMixin, Notification): @@ -47,23 +45,11 @@ class SubscriptionRequiredNotification(SubscriptionNotificationMixin, Notificati subject = 'We hope you enjoyed your trial of Read the Docs!' level = REQUIREMENT - @classmethod - def for_organizations(cls): - advanced_plan_subscription_trial_ended = ( - Organization.objects.subscription_trial_ended() - .created_days_ago(days=30) - ) - trial_plan_subscription_ended = ( - Organization.objects.subscription_trial_plan_ended() - .created_days_ago(days=30) - ) - return advanced_plan_subscription_trial_ended | trial_plan_subscription_ended - class SubscriptionEndedNotification(SubscriptionNotificationMixin, Notification): """ - Subscription has ended days ago. + Subscription has ended. Notify the customer that the Organization will be disabled *soon* if the subscription is not renewed for the organization. @@ -74,18 +60,8 @@ class SubscriptionEndedNotification(SubscriptionNotificationMixin, Notification) subject = 'Your subscription to Read the Docs has ended' level = REQUIREMENT - days_after_end = 5 - @classmethod - def for_organizations(cls): - organizations = Organization.objects.disable_soon( - days=cls.days_after_end, - exact=True, - ) - return organizations - - -class OrganizationDisabledNotification(SubscriptionEndedNotification): +class OrganizationDisabledNotification(SubscriptionNotificationMixin, Notification): """ Subscription has ended a month ago. @@ -95,11 +71,21 @@ class OrganizationDisabledNotification(SubscriptionEndedNotification): customer was notified twice. """ - name = 'organization_disabled' - subject = 'Your Read the Docs organization will be disabled soon' + name = "organization_disabled" + context_object_name = "organization" + subject = "Your Read the Docs organization will be disabled soon" + level = REQUIREMENT days_after_end = DISABLE_AFTER_DAYS + @classmethod + def for_organizations(cls): + organizations = Organization.objects.disable_soon( + days=cls.days_after_end, + exact=True, + ) + return organizations + class OrganizationDisabledSiteNotification(SubscriptionNotificationMixin, SiteNotification): diff --git a/readthedocs/subscriptions/querysets.py b/readthedocs/subscriptions/querysets.py new file mode 100644 index 00000000000..82ca0eaa5b9 --- /dev/null +++ b/readthedocs/subscriptions/querysets.py @@ -0,0 +1,31 @@ +""" +Querysets for djstripe models. + +Since djstripe is a third-party app, +these are injected at runtime at readthedocs/subscriptions/apps.py. +""" +from datetime import timedelta + +from django.db import models +from django.utils import timezone +from djstripe.enums import SubscriptionStatus + + +class StripeSubscriptionQueryset(models.QuerySet): + + """Manager for the djstripe Subscription model.""" + + def trial_ending(self, days=7): + """Get all subscriptions where their trial will end in the next `days`.""" + now = timezone.now() + ending = now + timedelta(days=days) + return self.filter( + status=SubscriptionStatus.trialing, + trial_end__gt=now, + trial_end__lt=ending, + ).distinct() + + def created_days_ago(self, days): + """Get all subscriptions that were created exactly `days` ago.""" + when = timezone.now() - timedelta(days=days) + return self.filter(created__date=when) diff --git a/readthedocs/subscriptions/tasks.py b/readthedocs/subscriptions/tasks.py index 3d5e83408b2..a2f3bc2b59f 100644 --- a/readthedocs/subscriptions/tasks.py +++ b/readthedocs/subscriptions/tasks.py @@ -4,7 +4,6 @@ import structlog from django.contrib.auth.models import User from django.db.models import Count -from django.http import HttpRequest from django.utils import timezone from readthedocs.builds.models import Build @@ -14,45 +13,50 @@ from readthedocs.subscriptions.models import Subscription from readthedocs.subscriptions.notifications import ( OrganizationDisabledNotification, - SubscriptionEndedNotification, - SubscriptionRequiredNotification, TrialEndingNotification, ) from readthedocs.worker import app - log = structlog.get_logger(__name__) @app.task(queue='web') def daily_email(): """Daily email beat task for organization notifications.""" - notifications = ( - TrialEndingNotification, - SubscriptionRequiredNotification, - SubscriptionEndedNotification, - OrganizationDisabledNotification, - ) - - orgs_sent = Organization.objects.none() + organizations = OrganizationDisabledNotification.for_organizations().distinct() + for organization in organizations: + for owner in organization.owners.all(): + notification = OrganizationDisabledNotification( + context_object=organization, + user=owner, + ) + log.info( + "Notification sent.", + recipient=owner, + organization_slug=organization.slug, + ) + notification.send() + + for subscription in TrialEndingNotification.for_subscriptions(): + organization = subscription.customer.rtd_organization + if not organization: + log.error( + "Susbscription isn't attached to an organization", + stripe_subscription_id=subscription.id, + ) + continue - for cls in notifications: - orgs = cls.for_organizations().exclude(id__in=orgs_sent).distinct() - orgs_sent |= orgs - for org in orgs: + for owner in organization.owners.all(): + notification = TrialEndingNotification( + context_object=organization, + user=owner, + ) log.info( - 'Sending notification', - notification_name=cls.name, - organization_slug=org.slug, + "Notification sent.", + recipient=owner, + organization_slug=organization.slug, ) - for owner in org.owners.all(): - notification = cls( - context_object=org, - request=HttpRequest(), - user=owner, - ) - log.info('Notification sent.', recipient=owner) - notification.send() + notification.send() @app.task(queue='web') diff --git a/readthedocs/subscriptions/tests/test_daily_email.py b/readthedocs/subscriptions/tests/test_daily_email.py index c663ad22620..d79ab251bb1 100644 --- a/readthedocs/subscriptions/tests/test_daily_email.py +++ b/readthedocs/subscriptions/tests/test_daily_email.py @@ -2,8 +2,12 @@ from unittest import mock import django_dynamic_fixture as fixture +from django.contrib.auth.models import User from django.test import TestCase from django.utils import timezone +from django_dynamic_fixture import get +from djstripe import models as djstripe +from djstripe.enums import SubscriptionStatus from readthedocs.organizations.models import Organization, OrganizationOwner from readthedocs.subscriptions.models import Subscription @@ -16,23 +20,33 @@ class DailyEmailTests(TestCase): def test_trial_ending(self, mock_storage_class, mock_send_email): """Trial ending daily email.""" - org1 = fixture.get( - Organization, - pub_date=timezone.now() - timedelta(days=24), - subscription=None, - ) - owner1 = fixture.get( - OrganizationOwner, - organization=org1, - ) - org2 = fixture.get( - Organization, - pub_date=timezone.now() - timedelta(days=25), - subscription=None, - ) - owner2 = fixture.get( - OrganizationOwner, - organization=org2, + now = timezone.now() + + owner1 = get(User) + org1 = get(Organization, owners=[owner1]) + customer1 = get(djstripe.Customer) + org1.stripe_customer = customer1 + org1.save() + get( + djstripe.Subscription, + status=SubscriptionStatus.trialing, + trial_start=now, + trial_end=now + timedelta(days=7), + created=now - timedelta(days=24), + customer=customer1, + ) + + owner2 = get(User) + org2 = fixture.get(Organization, owners=[owner2]) + customer2 = get(djstripe.Customer) + org2.stripe_customer = customer2 + org2.save() + get( + djstripe.Subscription, + status=SubscriptionStatus.trialing, + trial_start=now, + trial_end=now + timedelta(days=7), + created=now - timedelta(days=25), ) mock_storage = mock.Mock() @@ -41,122 +55,29 @@ def test_trial_ending(self, mock_storage_class, mock_send_email): daily_email() self.assertEqual(mock_storage.add.call_count, 1) - mock_storage.add.assert_has_calls([ - mock.call( - message=mock.ANY, - extra_tags='', - level=31, - user=owner1.owner, - ), - ]) - self.assertEqual(mock_send_email.call_count, 1) - mock_send_email.assert_has_calls([ - mock.call( - subject='Your trial is ending soon', - recipient=owner1.owner.email, - template=mock.ANY, - template_html=mock.ANY, - context=mock.ANY, - ), - ]) - - def test_trial_ended(self, mock_storage_class, mock_send_email): - """Trial ended daily email.""" - org1 = fixture.get( - Organization, - pub_date=timezone.now() - timedelta(days=30), - subscription=None, - ) - owner1 = fixture.get( - OrganizationOwner, - organization=org1, - ) - org2 = fixture.get( - Organization, - pub_date=timezone.now() - timedelta(days=31), - subscription=None, - ) - owner2 = fixture.get( - OrganizationOwner, - organization=org2, + mock_storage.add.assert_has_calls( + [ + mock.call( + message=mock.ANY, + extra_tags="", + level=31, + user=owner1, + ), + ] ) - - mock_storage = mock.Mock() - mock_storage_class.return_value = mock_storage - - daily_email() - - self.assertEqual(mock_storage.add.call_count, 1) - mock_storage.add.assert_has_calls([ - mock.call( - message=mock.ANY, - extra_tags='', - level=31, - user=owner1.owner, - ), - ]) self.assertEqual(mock_send_email.call_count, 1) - mock_send_email.assert_has_calls([ - mock.call( - subject='We hope you enjoyed your trial of Read the Docs!', - recipient=owner1.owner.email, - template=mock.ANY, - template_html=mock.ANY, - context=mock.ANY, - ), - ]) - - def test_subscription_ended_days_ago( - self, - mock_storage_class, - mock_send_email, - ): - """Subscription ended some days ago daily email.""" - sub1 = fixture.get( - Subscription, - status='past_due', - end_date=timezone.now() - timedelta(days=5), - ) - owner1 = fixture.get( - OrganizationOwner, - organization=sub1.organization, - ) - - sub2 = fixture.get( - Subscription, - status='past_due', - end_date=timezone.now() - timedelta(days=3), - ) - owner2 = fixture.get( - OrganizationOwner, - organization=sub2.organization, + mock_send_email.assert_has_calls( + [ + mock.call( + subject="Your trial is ending soon", + recipient=owner1.email, + template=mock.ANY, + template_html=mock.ANY, + context=mock.ANY, + ), + ] ) - mock_storage = mock.Mock() - mock_storage_class.return_value = mock_storage - - daily_email() - - self.assertEqual(mock_storage.add.call_count, 1) - mock_storage.add.assert_has_calls([ - mock.call( - message=mock.ANY, - extra_tags='', - level=31, - user=owner1.owner, - ), - ]) - self.assertEqual(mock_send_email.call_count, 1) - mock_send_email.assert_has_calls([ - mock.call( - subject='Your subscription to Read the Docs has ended', - recipient=owner1.owner.email, - template=mock.ANY, - template_html=mock.ANY, - context=mock.ANY, - ), - ]) - def test_organization_disabled(self, mock_storage_class, mock_send_email): """Subscription ended ``DISABLE_AFTER_DAYS`` days ago daily email.""" sub1 = fixture.get( diff --git a/readthedocs/subscriptions/tests/test_event_handlers.py b/readthedocs/subscriptions/tests/test_event_handlers.py index d28e6bd2998..e3e821113f8 100644 --- a/readthedocs/subscriptions/tests/test_event_handlers.py +++ b/readthedocs/subscriptions/tests/test_event_handlers.py @@ -1,5 +1,6 @@ from unittest import mock +from django.contrib.auth.models import User from django.test import TestCase, override_settings from django.utils import timezone from django_dynamic_fixture import get @@ -21,7 +22,10 @@ class TestStripeEventHandlers(TestCase): """Tests for Stripe API endpoint.""" def setUp(self): - self.organization = get(Organization, slug="org", email="test@example.com") + self.user = get(User) + self.organization = get( + Organization, slug="org", email="test@example.com", owners=[self.user] + ) get(Plan, stripe_id="trialing", slug="trialing") def test_subscription_updated_event(self): @@ -281,6 +285,76 @@ def test_customer_updated_event(self): self.organization.refresh_from_db() self.assertEqual(self.organization.email, customer.email) + @mock.patch( + "readthedocs.subscriptions.event_handlers.SubscriptionRequiredNotification.send" + ) + def test_subscription_canceled_trial_subscription(self, notification_send): + customer = get(djstripe.Customer) + self.organization.stripe_customer = customer + self.organization.save() + + start_date = timezone.now() + end_date = timezone.now() + timezone.timedelta(days=30) + stripe_subscription = get( + djstripe.Subscription, + id="sub_9LtsU02uvjO6Ed", + status=SubscriptionStatus.canceled, + current_period_start=start_date, + current_period_end=end_date, + trial_end=end_date, + customer=customer, + ) + price = get(djstripe.Price, id="trialing") + get( + djstripe.SubscriptionItem, + id="si_KOcEsHCktPUedU", + price=price, + subscription=stripe_subscription, + ) + + event = get( + djstripe.Event, + data={ + "object": { + "id": stripe_subscription.id, + "object": "subscription", + } + }, + ) + event_handlers.subscription_canceled(event) + notification_send.assert_called_once() + + @mock.patch( + "readthedocs.subscriptions.event_handlers.SubscriptionEndedNotification.send" + ) + def test_subscription_canceled(self, notification_send): + customer = get(djstripe.Customer) + self.organization.stripe_customer = customer + self.organization.save() + + start_date = timezone.now() + end_date = timezone.now() + timezone.timedelta(days=30) + stripe_subscription = get( + djstripe.Subscription, + id="sub_9LtsU02uvjO6Ed", + status=SubscriptionStatus.canceled, + current_period_start=start_date, + current_period_end=end_date, + trial_end=end_date, + customer=customer, + ) + event = get( + djstripe.Event, + data={ + "object": { + "id": stripe_subscription.id, + "object": "subscription", + } + }, + ) + event_handlers.subscription_canceled(event) + notification_send.assert_called_once() + def test_register_events(self): def test_func(): pass