Skip to content

Commit 6895018

Browse files
authored
Move subscription models (#8746)
* Subscriptions: move models * Fix imports & Linter
1 parent 3bbe2ac commit 6895018

File tree

9 files changed

+727
-0
lines changed

9 files changed

+727
-0
lines changed

readthedocs/settings/base.py

+1
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def INSTALLED_APPS(self): # noqa
194194

195195
'readthedocs.gold',
196196
'readthedocs.payments',
197+
'readthedocs.subscriptions',
197198
'readthedocs.notifications',
198199
'readthedocs.integrations',
199200
'readthedocs.analytics',

readthedocs/subscriptions/__init__.py

Whitespace-only changes.

readthedocs/subscriptions/admin.py

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Admin interface for subscription models."""
2+
3+
from datetime import timedelta
4+
5+
from django.contrib import admin
6+
from django.db.models import Q
7+
from django.utils import timezone
8+
from django.utils.html import format_html
9+
10+
from readthedocs.core.history import ExtraSimpleHistoryAdmin
11+
from readthedocs.subscriptions.models import Plan, PlanFeature, Subscription
12+
13+
14+
class PlanFeatureInline(admin.TabularInline):
15+
model = PlanFeature
16+
17+
18+
class PlanAdmin(admin.ModelAdmin):
19+
prepopulated_fields = {'slug': ('name',)}
20+
list_display = (
21+
'name',
22+
'slug',
23+
'price',
24+
'trial',
25+
'published',
26+
)
27+
filter_horizontal = ('for_organizations',)
28+
inlines = (PlanFeatureInline,)
29+
30+
31+
class PlanFeatureAdmin(admin.ModelAdmin):
32+
list_display = ('get_feature_type_display', 'plan')
33+
list_select_related = ('plan',)
34+
search_fields = ('plan__name', 'feature_type')
35+
36+
37+
class SubscriptionDateFilter(admin.SimpleListFilter):
38+
39+
"""Filter for the status of the subscriptions related to their date."""
40+
41+
title = 'subscription date'
42+
parameter_name = 'subscription_date'
43+
44+
TRIALING = 'trialing'
45+
TRIAL_ENDING = 'trial_ending'
46+
TRIAL_ENDED = 'trial_ended'
47+
EXPIRED = 'expired'
48+
49+
def lookups(self, request, model_admin):
50+
return (
51+
(self.TRIALING, 'trial active'),
52+
(self.TRIAL_ENDING, 'trial ending'),
53+
(self.TRIAL_ENDED, 'trial ended'),
54+
(self.EXPIRED, 'subscription expired'),
55+
)
56+
57+
def queryset(self, request, queryset):
58+
trial_queryset = (
59+
queryset.filter(
60+
Q(status='trialing') |
61+
Q(status__isnull=True),
62+
),
63+
) # yapf: disabled
64+
if self.value() == self.TRIALING:
65+
return trial_queryset.filter(trial_end_date__gt=timezone.now(),)
66+
if self.value() == self.TRIAL_ENDING:
67+
return trial_queryset.filter(
68+
trial_end_date__lt=timezone.now() + timedelta(days=7),
69+
trial_end_date__gt=timezone.now(),
70+
)
71+
if self.value() == self.TRIAL_ENDED:
72+
return trial_queryset.filter(trial_end_date__lt=timezone.now(),)
73+
if self.value() == self.EXPIRED:
74+
return queryset.filter(end_date__lt=timezone.now())
75+
76+
77+
class SubscriptionAdmin(ExtraSimpleHistoryAdmin):
78+
model = Subscription
79+
list_display = ('organization', 'plan', 'status', 'stripe_subscription', 'trial_end_date')
80+
list_filter = ('status', SubscriptionDateFilter, 'plan')
81+
list_select_related = ('organization', 'plan')
82+
raw_id_fields = ('organization',)
83+
readonly_fields = ('stripe_subscription',)
84+
search_fields = ('organization__name', 'stripe_id')
85+
86+
# pylint: disable=no-self-use
87+
def stripe_subscription(self, obj):
88+
if obj.stripe_id:
89+
return format_html(
90+
'<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
91+
"https://dashboard.stripe.com/subscriptions/{}".format(obj.stripe_id),
92+
obj.stripe_id,
93+
)
94+
return None
95+
96+
97+
admin.site.register(Subscription, SubscriptionAdmin)
98+
admin.site.register(Plan, PlanAdmin)
99+
admin.site.register(PlanFeature, PlanFeatureAdmin)

readthedocs/subscriptions/apps.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Subscriptions app."""
2+
3+
from django.apps import AppConfig
4+
5+
6+
class SubscriptionsConfig(AppConfig):
7+
name = 'subscriptions'

readthedocs/subscriptions/managers.py

+220
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Subscriptions managers."""
2+
3+
from datetime import datetime
4+
5+
import structlog
6+
from django.conf import settings
7+
from django.db import models
8+
from django.utils import timezone
9+
10+
from readthedocs.core.history import set_change_reason
11+
from readthedocs.subscriptions.utils import get_or_create_stripe_customer
12+
13+
log = structlog.get_logger(__name__)
14+
15+
16+
class SubscriptionManager(models.Manager):
17+
18+
"""Model manager for Subscriptions."""
19+
20+
def get_or_create_default_subscription(self, organization):
21+
"""
22+
Get or create a trialing subscription for `organization`.
23+
24+
If the organization doesn't have a subscription attached,
25+
the following steps are executed.
26+
27+
- If the organization doesn't have a stripe customer, one is created.
28+
- A new stripe subscription is created using the default plan.
29+
- A new subscription object is created in our database
30+
with the information from the stripe subscription.
31+
"""
32+
if hasattr(organization, 'subscription'):
33+
return organization.subscription
34+
35+
from readthedocs.subscriptions.models import Plan
36+
plan = Plan.objects.filter(slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG).first()
37+
# This should happen only on development.
38+
if not plan:
39+
log.warning(
40+
'No default plan found, not creating a subscription.',
41+
organization_slug=organization.slug,
42+
)
43+
return None
44+
45+
stripe_customer = get_or_create_stripe_customer(organization)
46+
stripe_subscription = stripe_customer.subscriptions.create(
47+
plan=plan.stripe_id,
48+
trial_period_days=plan.trial,
49+
)
50+
return self.create(
51+
plan=plan,
52+
organization=organization,
53+
stripe_id=stripe_subscription.id,
54+
status=stripe_subscription.status,
55+
start_date=timezone.make_aware(
56+
datetime.fromtimestamp(int(stripe_subscription.start)),
57+
),
58+
end_date=timezone.make_aware(
59+
datetime.fromtimestamp(int(stripe_subscription.current_period_end)),
60+
),
61+
trial_end_date=timezone.make_aware(
62+
datetime.fromtimestamp(int(stripe_subscription.trial_end)),
63+
),
64+
)
65+
66+
def update_from_stripe(self, *, rtd_subscription, stripe_subscription):
67+
"""
68+
Update the RTD subscription object with the information of the stripe subscription.
69+
70+
:param subscription: Subscription object to update.
71+
:param stripe_subscription: Stripe subscription object from API
72+
:type stripe_subscription: stripe.Subscription
73+
"""
74+
# Documentation doesn't say what will be this value once the
75+
# subscription is ``canceled``. I'm assuming that ``current_period_end``
76+
# will have the same value than ``ended_at``
77+
# https://stripe.com/docs/api/subscriptions/object?lang=python#subscription_object-current_period_end
78+
start_date = getattr(stripe_subscription, 'current_period_start', None)
79+
end_date = getattr(stripe_subscription, 'current_period_end', None)
80+
81+
try:
82+
start_date = timezone.make_aware(
83+
datetime.fromtimestamp(start_date),
84+
)
85+
end_date = timezone.make_aware(
86+
datetime.fromtimestamp(end_date),
87+
)
88+
except TypeError:
89+
log.error(
90+
'Stripe subscription invalid date.',
91+
start_date=start_date,
92+
end_date=end_date,
93+
stripe_subscription=stripe_subscription.id,
94+
)
95+
start_date = None
96+
end_date = None
97+
trial_end_date = None
98+
99+
rtd_subscription.status = stripe_subscription.status
100+
101+
# This should only happen if an existing user creates a new subscription,
102+
# after their previous subscription was cancelled.
103+
if stripe_subscription.id != rtd_subscription.stripe_id:
104+
log.info(
105+
'Replacing stripe subscription.',
106+
old_stripe_subscription=rtd_subscription.stripe_id,
107+
new_stripe_subscription=stripe_subscription.id,
108+
)
109+
rtd_subscription.stripe_id = stripe_subscription.id
110+
111+
# Update trial end date if it's present
112+
trial_end_date = getattr(stripe_subscription, 'trial_end', None)
113+
if trial_end_date:
114+
try:
115+
trial_end_date = timezone.make_aware(
116+
datetime.fromtimestamp(trial_end_date),
117+
)
118+
rtd_subscription.trial_end_date = trial_end_date
119+
except TypeError:
120+
log.error(
121+
'Stripe subscription trial end date invalid. ',
122+
trial_end_date=trial_end_date,
123+
stripe_subscription=stripe_subscription.id,
124+
)
125+
126+
# Update the plan in case it was changed from the Portal.
127+
# Try our best to match a plan that is not custom. This mostly just
128+
# updates the UI now that we're using the Stripe Portal. A miss here
129+
# just won't update the UI, but this shouldn't happen for most users.
130+
from readthedocs.subscriptions.models import Plan
131+
try:
132+
plan = (
133+
Plan.objects
134+
# Exclude "custom" here, as we historically reused Stripe plan
135+
# id for custom plans. We don't have a better attribute to
136+
# filter on here.
137+
.exclude(slug__contains='custom')
138+
.exclude(name__icontains='Custom')
139+
.get(stripe_id=stripe_subscription.plan.id)
140+
)
141+
rtd_subscription.plan = plan
142+
except (Plan.DoesNotExist, Plan.MultipleObjectsReturned):
143+
log.error(
144+
'Plan lookup failed, skipping plan update.',
145+
stripe_subscription=stripe_subscription.id,
146+
stripe_plan=stripe_subscription.plan.id,
147+
)
148+
149+
if stripe_subscription.status == 'canceled':
150+
# Remove ``stripe_id`` when canceled so the customer can
151+
# re-subscribe using our form.
152+
rtd_subscription.stripe_id = None
153+
154+
elif stripe_subscription.status == 'active' and end_date:
155+
# Save latest active date (end_date) to notify owners about their subscription
156+
# is ending and disable this organization after N days of unpaid. We check for
157+
# ``active`` here because Stripe will continue sending updates for the
158+
# subscription, with a new ``end_date``, even after the subscription enters
159+
# an unpaid state.
160+
rtd_subscription.end_date = end_date
161+
162+
elif stripe_subscription.status == 'past_due' and start_date:
163+
# When Stripe marks the subscription as ``past_due``,
164+
# it means the usage of RTD service for the current period/month was not paid at all.
165+
# At this point, we need to update our ``end_date`` to the last period the customer paid
166+
# (which is the start date of the current ``past_due`` period --it could be the end date
167+
# of the trial or the end date of the last paid period).
168+
rtd_subscription.end_date = start_date
169+
170+
klass = self.__class__.__name__
171+
change_reason = f'origin=stripe-subscription class={klass}'
172+
173+
# Ensure that the organization is in the correct state.
174+
# We want to always ensure the organization is never disabled
175+
# if the subscription is valid.
176+
organization = rtd_subscription.organization
177+
if stripe_subscription.status == 'active' and organization.disabled:
178+
log.warning(
179+
'Re-enabling organization with valid subscription.',
180+
organization_slug=organization.slug,
181+
stripe_subscription=rtd_subscription.id,
182+
)
183+
organization.disabled = False
184+
set_change_reason(organization, change_reason)
185+
organization.save()
186+
187+
set_change_reason(rtd_subscription, change_reason)
188+
rtd_subscription.save()
189+
return rtd_subscription
190+
191+
192+
class PlanFeatureManager(models.Manager):
193+
194+
"""Model manager for PlanFeature."""
195+
196+
# pylint: disable=redefined-builtin
197+
def get_feature(self, obj, type):
198+
"""
199+
Get feature `type` for `obj`.
200+
201+
:param obj: An organization or project instance.
202+
:param type: The type of the feature (PlanFeature.TYPE_*).
203+
:returns: A PlanFeature object or None.
204+
"""
205+
# Avoid circular imports.
206+
from readthedocs.organizations.models import Organization
207+
from readthedocs.projects.models import Project
208+
209+
if isinstance(obj, Project):
210+
organization = obj.organizations.first()
211+
elif isinstance(obj, Organization):
212+
organization = obj
213+
else:
214+
raise TypeError
215+
216+
feature = self.filter(
217+
feature_type=type,
218+
plan__subscriptions__organization=organization,
219+
)
220+
return feature.first()

0 commit comments

Comments
 (0)