Skip to content

Commit 417450f

Browse files
committed
Subscriptions: use djstripe events to mail owners
1 parent b280817 commit 417450f

File tree

10 files changed

+262
-276
lines changed

10 files changed

+262
-276
lines changed

readthedocs/notifications/notification.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
1-
# -*- coding: utf-8 -*-
21

32
"""Support for templating of notifications."""
43

54
import structlog
6-
from readthedocs.core.context_processors import readthedocs_processor
7-
85
from django.conf import settings
96
from django.db import models
107
from django.http import HttpRequest
118
from django.template import Context, Template
129
from django.template.loader import render_to_string
1310

11+
from readthedocs.core.context_processors import readthedocs_processor
12+
1413
from . import constants
1514
from .backends import send_notification
1615

17-
1816
log = structlog.get_logger(__name__)
1917

2018

@@ -39,9 +37,9 @@ class Notification:
3937
send_email = True
4038
extra_tags = ''
4139

42-
def __init__(self, context_object, request, user=None):
40+
def __init__(self, context_object, request=None, user=None):
4341
self.object = context_object
44-
self.request = request
42+
self.request = request or HttpRequest()
4543
self.user = user
4644
if self.user is None:
4745
self.user = request.user

readthedocs/organizations/querysets.py

Lines changed: 0 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -38,78 +38,6 @@ def created_days_ago(self, days, field='pub_date'):
3838
query_filter[field + '__day'] = when.day
3939
return self.filter(**query_filter)
4040

41-
def subscription_trialing(self):
42-
"""
43-
Organizations with subscriptions in a trialing state.
44-
45-
Trialing state is defined by either having a subscription in the
46-
trialing state or by having an organization created in the last 30 days
47-
"""
48-
date_now = timezone.now()
49-
return self.filter(
50-
Q(
51-
(
52-
Q(
53-
subscription__status='trialing',
54-
) | Q(
55-
subscription__status__exact='',
56-
)
57-
),
58-
subscription__trial_end_date__gt=date_now,
59-
) | Q(
60-
subscription__isnull=True,
61-
pub_date__gt=date_now - timedelta(days=30),
62-
),
63-
)
64-
65-
def subscription_trial_ending(self):
66-
"""
67-
Organizations with subscriptions in trial ending state.
68-
69-
If the organization subscription is either explicitly in a trialing
70-
state, or at least has trial end date in the trial ending date range,
71-
consider this organization's subscription trial ending. Also, if the
72-
subscription is null, use the organization creation date to calculate a
73-
trial end date instead.
74-
"""
75-
date_now = timezone.now()
76-
date_next_week = date_now + timedelta(days=7)
77-
78-
# TODO: this can be refactored to use
79-
# ``self.subscription_trialing`` and add the 7 days filter to
80-
# that response
81-
return self.filter(
82-
Q(
83-
(
84-
Q(
85-
subscription__status='trialing',
86-
) | Q(
87-
subscription__status__exact='',
88-
)
89-
),
90-
subscription__trial_end_date__lt=date_next_week,
91-
subscription__trial_end_date__gt=date_now,
92-
) | Q(
93-
subscription__isnull=True,
94-
pub_date__lt=date_next_week - timedelta(days=30),
95-
pub_date__gt=date_now - timedelta(days=30),
96-
),
97-
)
98-
99-
# TODO: once we are settled into the Trial Plan, merge this method with the
100-
# previous one (``subscription_trial_ending``)
101-
def subscription_trial_plan_ending(self):
102-
"""Organizations with subscriptions to Trial Plan about to end."""
103-
date_now = timezone.now()
104-
date_next_week = date_now + timedelta(days=7)
105-
106-
return self.filter(
107-
subscription__plan__stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE,
108-
subscription__status='trialing',
109-
subscription__trial_end_date__lt=date_next_week,
110-
subscription__trial_end_date__gt=date_now,
111-
)
112-
11341
# TODO: once we are settled into the Trial Plan, merge this method with the
11442
# following one (``subscription_trial_ended``).
11543
def subscription_trial_plan_ended(self):

readthedocs/rtd_tests/tests/test_notifications.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,10 @@ def setUp(self):
229229

230230
def test_context_data(self):
231231
context = {
232-
'object': {'name': 'object name'},
233-
'request': None,
234-
'production_uri': 'https://readthedocs.org',
235-
'other': {'name': 'other name'},
236-
232+
"object": {"name": "object name"},
233+
"request": mock.ANY,
234+
"production_uri": "https://readthedocs.org",
235+
"other": {"name": "other name"},
237236
# readthedocs_processor context
238237
'DASHBOARD_ANALYTICS_CODE': mock.ANY,
239238
'DO_NOT_TRACK_ENABLED': mock.ANY,

readthedocs/subscriptions/apps.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,14 @@ def ready(self):
1111
import readthedocs.subscriptions.event_handlers # noqa
1212
import readthedocs.subscriptions.signals # noqa
1313
import readthedocs.subscriptions.tasks # noqa
14+
15+
self._add_custom_manager()
16+
17+
# pylint: disable=no-self-use
18+
def _add_custom_manager(self):
19+
from djstripe.models import Subscription
20+
21+
from readthedocs.subscriptions.querysets import StripeSubscriptionQueryset
22+
23+
manager = StripeSubscriptionQueryset.as_manager()
24+
manager.contribute_to_class(Subscription, "rtd")

readthedocs/subscriptions/event_handlers.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
from readthedocs.organizations.models import Organization
1414
from readthedocs.payments.utils import cancel_subscription as cancel_stripe_subscription
1515
from readthedocs.subscriptions.models import Subscription
16+
from readthedocs.subscriptions.notifications import (
17+
SubscriptionEndedNotification,
18+
SubscriptionRequiredNotification,
19+
)
1620

1721
log = structlog.get_logger(__name__)
1822

@@ -92,6 +96,41 @@ def update_subscription(event):
9296
)
9397

9498

99+
@handler("customer.subscription.deleted")
100+
def subscription_canceled(event):
101+
"""Send a notification to all owners if the subscription has ended."""
102+
stripe_subscription_id = event.data["object"]["id"]
103+
log.bind(stripe_subscription_id=stripe_subscription_id)
104+
stripe_subscription = djstripe.Subscription.objects.filter(
105+
id=stripe_subscription_id
106+
).first()
107+
if not stripe_subscription:
108+
log.info("Stripe subscription not found.")
109+
return
110+
111+
organization = stripe_subscription.customer.rtd_organization
112+
if not organization:
113+
log.error("Subscription isn't attached to an organization")
114+
return
115+
116+
log.bind(organization_slug=organization.slug)
117+
is_trial_subscription = stripe_subscription.items.filter(
118+
price__id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE
119+
).exists()
120+
notification_class = (
121+
SubscriptionRequiredNotification
122+
if is_trial_subscription
123+
else SubscriptionEndedNotification
124+
)
125+
for owner in organization.owners.all():
126+
notification = notification_class(
127+
context_object=organization,
128+
user=owner,
129+
)
130+
notification.send()
131+
log.info("Notification sent.", recipient=owner)
132+
133+
95134
@handler("checkout.session.completed")
96135
def checkout_completed(event):
97136
"""

readthedocs/subscriptions/notifications.py

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Organization level notifications."""
22

3+
34
from django.urls import reverse
5+
from djstripe import models as djstripe
46
from messages_extends.constants import WARNING_PERSISTENT
57

68
from readthedocs.notifications import Notification, SiteNotification
@@ -25,17 +27,13 @@ class TrialEndingNotification(SubscriptionNotificationMixin, Notification):
2527
subject = 'Your trial is ending soon'
2628
level = REQUIREMENT
2729

28-
@classmethod
29-
def for_organizations(cls):
30-
advanced_plan_subscription_trial_ending = (
31-
Organization.objects.subscription_trial_ending()
32-
.created_days_ago(days=24)
33-
)
34-
trial_plan_subscription_trial_ending = (
35-
Organization.objects.subscription_trial_plan_ending()
36-
.created_days_ago(days=24)
30+
@staticmethod
31+
def for_subscriptions():
32+
return (
33+
djstripe.Subscription.rtd.trial_ending()
34+
.created_days_ago(24)
35+
.prefetch_related("customer__rtd_organization")
3736
)
38-
return advanced_plan_subscription_trial_ending | trial_plan_subscription_trial_ending
3937

4038

4139
class SubscriptionRequiredNotification(SubscriptionNotificationMixin, Notification):
@@ -47,23 +45,11 @@ class SubscriptionRequiredNotification(SubscriptionNotificationMixin, Notificati
4745
subject = 'We hope you enjoyed your trial of Read the Docs!'
4846
level = REQUIREMENT
4947

50-
@classmethod
51-
def for_organizations(cls):
52-
advanced_plan_subscription_trial_ended = (
53-
Organization.objects.subscription_trial_ended()
54-
.created_days_ago(days=30)
55-
)
56-
trial_plan_subscription_ended = (
57-
Organization.objects.subscription_trial_plan_ended()
58-
.created_days_ago(days=30)
59-
)
60-
return advanced_plan_subscription_trial_ended | trial_plan_subscription_ended
61-
6248

6349
class SubscriptionEndedNotification(SubscriptionNotificationMixin, Notification):
6450

6551
"""
66-
Subscription has ended days ago.
52+
Subscription has ended.
6753
6854
Notify the customer that the Organization will be disabled *soon* if the
6955
subscription is not renewed for the organization.
@@ -74,18 +60,8 @@ class SubscriptionEndedNotification(SubscriptionNotificationMixin, Notification)
7460
subject = 'Your subscription to Read the Docs has ended'
7561
level = REQUIREMENT
7662

77-
days_after_end = 5
7863

79-
@classmethod
80-
def for_organizations(cls):
81-
organizations = Organization.objects.disable_soon(
82-
days=cls.days_after_end,
83-
exact=True,
84-
)
85-
return organizations
86-
87-
88-
class OrganizationDisabledNotification(SubscriptionEndedNotification):
64+
class OrganizationDisabledNotification(SubscriptionNotificationMixin, Notification):
8965

9066
"""
9167
Subscription has ended a month ago.
@@ -95,11 +71,21 @@ class OrganizationDisabledNotification(SubscriptionEndedNotification):
9571
customer was notified twice.
9672
"""
9773

98-
name = 'organization_disabled'
99-
subject = 'Your Read the Docs organization will be disabled soon'
74+
name = "organization_disabled"
75+
context_object_name = "organization"
76+
subject = "Your Read the Docs organization will be disabled soon"
77+
level = REQUIREMENT
10078

10179
days_after_end = DISABLE_AFTER_DAYS
10280

81+
@classmethod
82+
def for_organizations(cls):
83+
organizations = Organization.objects.disable_soon(
84+
days=cls.days_after_end,
85+
exact=True,
86+
)
87+
return organizations
88+
10389

10490
class OrganizationDisabledSiteNotification(SubscriptionNotificationMixin, SiteNotification):
10591

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""
2+
Querysets for djstripe models.
3+
4+
Since djstripe is a third-party app,
5+
these are injected at runtime at readthedocs/subscriptions/apps.py.
6+
"""
7+
from datetime import timedelta
8+
9+
from django.db import models
10+
from django.utils import timezone
11+
from djstripe.enums import SubscriptionStatus
12+
13+
14+
class StripeSubscriptionQueryset(models.QuerySet):
15+
def trial_ending(self, days=7):
16+
now = timezone.now()
17+
ending = now + timedelta(days=days)
18+
return self.filter(
19+
status=SubscriptionStatus.trialing,
20+
trial_end__gt=now,
21+
trial_end__lt=ending,
22+
).distinct()
23+
24+
def created_days_ago(self, days):
25+
when = timezone.now() - timedelta(days=days)
26+
return self.filter(created__date=when)

0 commit comments

Comments
 (0)