Skip to content

Subscriptions: use djstripe events to mail owners #9661

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Nov 7, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions readthedocs/notifications/notification.py
Original file line number Diff line number Diff line change
@@ -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__)


Expand All @@ -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()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we were requiring passing this parameter, and always using HttpRequest when we didn't have it, so looks like a good default. Doesn't look like we are using it for making decisions.

self.user = user
if self.user is None:
self.user = request.user
Expand Down
72 changes: 0 additions & 72 deletions readthedocs/organizations/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 4 additions & 5 deletions readthedocs/rtd_tests/tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions readthedocs/subscriptions/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,14 @@ 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):
from djstripe.models import Subscription

from readthedocs.subscriptions.querysets import StripeSubscriptionQueryset

manager = StripeSubscriptionQueryset.as_manager()
manager.contribute_to_class(Subscription, "rtd")
45 changes: 45 additions & 0 deletions readthedocs/subscriptions/event_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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):
"""
Expand Down
58 changes: 22 additions & 36 deletions readthedocs/subscriptions/notifications.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.rtd.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):
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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):

Expand Down
26 changes: 26 additions & 0 deletions readthedocs/subscriptions/querysets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
Querysets for djstripe models.
Since djstripe is a third-party app,
these are injected at runtime at readthedocs/subscriptions/apps.py.
Comment on lines +2 to +5
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can also be a collection of helpers, but using a queryset we don't need to import those and just use the model directly :)

"""
from datetime import timedelta

from django.db import models
from django.utils import timezone
from djstripe.enums import SubscriptionStatus


class StripeSubscriptionQueryset(models.QuerySet):
def trial_ending(self, days=7):
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):
when = timezone.now() - timedelta(days=days)
return self.filter(created__date=when)
Loading