From 032be149809fbefb5dd9e59d12fb099c77f68e33 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Mon, 6 Dec 2021 13:08:53 -0500 Subject: [PATCH 1/2] Subscriptions: move models --- readthedocs/subscriptions/__init__.py | 0 readthedocs/subscriptions/admin.py | 94 +++++++ readthedocs/subscriptions/apps.py | 5 + readthedocs/subscriptions/managers.py | 215 ++++++++++++++++ .../subscriptions/migrations/0001_squashed.py | 104 ++++++++ .../subscriptions/migrations/__init__.py | 0 readthedocs/subscriptions/models.py | 239 ++++++++++++++++++ readthedocs/subscriptions/utils.py | 51 ++++ 8 files changed, 708 insertions(+) create mode 100644 readthedocs/subscriptions/__init__.py create mode 100644 readthedocs/subscriptions/admin.py create mode 100644 readthedocs/subscriptions/apps.py create mode 100644 readthedocs/subscriptions/managers.py create mode 100644 readthedocs/subscriptions/migrations/0001_squashed.py create mode 100644 readthedocs/subscriptions/migrations/__init__.py create mode 100644 readthedocs/subscriptions/models.py create mode 100644 readthedocs/subscriptions/utils.py diff --git a/readthedocs/subscriptions/__init__.py b/readthedocs/subscriptions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/readthedocs/subscriptions/admin.py b/readthedocs/subscriptions/admin.py new file mode 100644 index 00000000000..eb3b43bca81 --- /dev/null +++ b/readthedocs/subscriptions/admin.py @@ -0,0 +1,94 @@ +from datetime import timedelta + +from django.contrib import admin +from django.db.models import Q +from django.utils import timezone +from django.utils.html import format_html + +from readthedocs.core.history import ExtraSimpleHistoryAdmin +from readthedocsinc.subscriptions.models import Plan, PlanFeature, Subscription + + +class PlanFeatureInline(admin.TabularInline): + model = PlanFeature + + +class PlanAdmin(admin.ModelAdmin): + prepopulated_fields = {'slug': ('name',)} + list_display = ( + 'name', + 'slug', + 'price', + 'trial', + 'published', + ) + filter_horizontal = ('for_organizations',) + inlines = (PlanFeatureInline,) + + +class PlanFeatureAdmin(admin.ModelAdmin): + list_display = ('get_feature_type_display', 'plan') + list_select_related = ('plan',) + search_fields = ('plan__name', 'feature_type') + + +class SubscriptionDateFilter(admin.SimpleListFilter): + + title = 'subscription date' + parameter_name = 'subscription_date' + + TRIALING = 'trialing' + TRIAL_ENDING = 'trial_ending' + TRIAL_ENDED = 'trial_ended' + EXPIRED = 'expired' + + def lookups(self, request, model_admin): + return ( + (self.TRIALING, 'trial active'), + (self.TRIAL_ENDING, 'trial ending'), + (self.TRIAL_ENDED, 'trial ended'), + (self.EXPIRED, 'subscription expired'), + ) + + def queryset(self, request, queryset): + trial_queryset = ( + queryset.filter( + Q(status='trialing') | + Q(status__isnull=True), + ), + ) # yapf: disabled + if self.value() == self.TRIALING: + return trial_queryset.filter(trial_end_date__gt=timezone.now(),) + if self.value() == self.TRIAL_ENDING: + return trial_queryset.filter( + trial_end_date__lt=timezone.now() + timedelta(days=7), + trial_end_date__gt=timezone.now(), + ) + if self.value() == self.TRIAL_ENDED: + return trial_queryset.filter(trial_end_date__lt=timezone.now(),) + if self.value() == self.EXPIRED: + return queryset.filter(end_date__lt=timezone.now()) + + +class SubscriptionAdmin(ExtraSimpleHistoryAdmin): + model = Subscription + list_display = ('organization', 'plan', 'status', 'stripe_subscription', 'trial_end_date') + list_filter = ('status', SubscriptionDateFilter, 'plan') + list_select_related = ('organization', 'plan') + raw_id_fields = ('organization',) + readonly_fields = ('stripe_subscription',) + search_fields = ('organization__name', 'stripe_id') + + def stripe_subscription(self, obj): + if obj.stripe_id: + return format_html( + '{}', + "https://dashboard.stripe.com/subscriptions/{}".format(obj.stripe_id), + obj.stripe_id, + ) + return None + + +admin.site.register(Subscription, SubscriptionAdmin) +admin.site.register(Plan, PlanAdmin) +admin.site.register(PlanFeature, PlanFeatureAdmin) diff --git a/readthedocs/subscriptions/apps.py b/readthedocs/subscriptions/apps.py new file mode 100644 index 00000000000..bb0a84cb6a2 --- /dev/null +++ b/readthedocs/subscriptions/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SubscriptionsConfig(AppConfig): + name = 'subscriptions' diff --git a/readthedocs/subscriptions/managers.py b/readthedocs/subscriptions/managers.py new file mode 100644 index 00000000000..2713f18d1f9 --- /dev/null +++ b/readthedocs/subscriptions/managers.py @@ -0,0 +1,215 @@ +import structlog +from datetime import datetime + +from django.conf import settings +from django.db import models +from django.utils import timezone + +from readthedocs.core.history import set_change_reason +from readthedocsinc.subscriptions.utils import get_or_create_stripe_customer + +log = structlog.get_logger(__name__) + + +class SubscriptionManager(models.Manager): + + """Model manager for Subscriptions.""" + + def get_or_create_default_subscription(self, organization): + """ + Get or create a trialing subscription for `organization`. + + If the organization doesn't have a subscription attached, + the following steps are executed. + + - If the organization doesn't have a stripe customer, one is created. + - A new stripe subscription is created using the default plan. + - A new subscription object is created in our database + with the information from the stripe subscription. + """ + if hasattr(organization, 'subscription'): + return organization.subscription + + from readthedocsinc.subscriptions.models import Plan + plan = Plan.objects.filter(slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG).first() + # This should happen only on development. + if not plan: + log.warning( + 'No default plan found, not creating a subscription.', + organization_slug=organization.slug, + ) + return None + + stripe_customer = get_or_create_stripe_customer(organization) + stripe_subscription = stripe_customer.subscriptions.create( + plan=plan.stripe_id, + trial_period_days=plan.trial, + ) + return self.create( + plan=plan, + organization=organization, + stripe_id=stripe_subscription.id, + status=stripe_subscription.status, + start_date=timezone.make_aware( + datetime.fromtimestamp(int(stripe_subscription.start)), + ), + end_date=timezone.make_aware( + datetime.fromtimestamp(int(stripe_subscription.current_period_end)), + ), + trial_end_date=timezone.make_aware( + datetime.fromtimestamp(int(stripe_subscription.trial_end)), + ), + ) + + def update_from_stripe(self, *, rtd_subscription, stripe_subscription): + """ + Update the RTD subscription object with the information of the stripe subscription. + + :param subscription: Subscription object to update. + :param stripe_subscription: Stripe subscription object from API + :type stripe_subscription: stripe.Subscription + """ + # Documentation doesn't say what will be this value once the + # subscription is ``canceled``. I'm assuming that ``current_period_end`` + # will have the same value than ``ended_at`` + # https://stripe.com/docs/api/subscriptions/object?lang=python#subscription_object-current_period_end + start_date = getattr(stripe_subscription, 'current_period_start', None) + end_date = getattr(stripe_subscription, 'current_period_end', None) + + try: + start_date = timezone.make_aware( + datetime.fromtimestamp(start_date), + ) + end_date = timezone.make_aware( + datetime.fromtimestamp(end_date), + ) + except TypeError: + log.error( + 'Stripe subscription invalid date.', + start_date=start_date, + end_date=end_date, + stripe_subscription=stripe_subscription.id, + ) + start_date = None + end_date = None + trial_end_date = None + + rtd_subscription.status = stripe_subscription.status + + # This should only happen if an existing user creates a new subscription, + # after their previous subscription was cancelled. + if stripe_subscription.id != rtd_subscription.stripe_id: + log.info( + 'Replacing stripe subscription.', + old_stripe_subscription=rtd_subscription.stripe_id, + new_stripe_subscription=stripe_subscription.id, + ) + rtd_subscription.stripe_id = stripe_subscription.id + + # Update trial end date if it's present + trial_end_date = getattr(stripe_subscription, 'trial_end', None) + if trial_end_date: + try: + trial_end_date = timezone.make_aware( + datetime.fromtimestamp(trial_end_date), + ) + rtd_subscription.trial_end_date = trial_end_date + except TypeError: + log.error( + 'Stripe subscription trial end date invalid. ', + trial_end_date=trial_end_date, + stripe_subscription=stripe_subscription.id, + ) + + # Update the plan in case it was changed from the Portal. + # Try our best to match a plan that is not custom. This mostly just + # updates the UI now that we're using the Stripe Portal. A miss here + # just won't update the UI, but this shouldn't happen for most users. + from readthedocsinc.subscriptions.models import Plan + try: + plan = ( + Plan.objects + # Exclude "custom" here, as we historically reused Stripe plan + # id for custom plans. We don't have a better attribute to + # filter on here. + .exclude(slug__contains='custom') + .exclude(name__icontains='Custom') + .get(stripe_id=stripe_subscription.plan.id) + ) + rtd_subscription.plan = plan + except (Plan.DoesNotExist, Plan.MultipleObjectsReturned): + log.error( + 'Plan lookup failed, skipping plan update.', + stripe_subscription=stripe_subscription.id, + stripe_plan=stripe_subscription.plan.id, + ) + + if stripe_subscription.status == 'canceled': + # Remove ``stripe_id`` when canceled so the customer can + # re-subscribe using our form. + rtd_subscription.stripe_id = None + + elif stripe_subscription.status == 'active' and end_date: + # Save latest active date (end_date) to notify owners about their subscription + # is ending and disable this organization after N days of unpaid. We check for + # ``active`` here because Stripe will continue sending updates for the + # subscription, with a new ``end_date``, even after the subscription enters + # an unpaid state. + rtd_subscription.end_date = end_date + + elif stripe_subscription.status == 'past_due' and start_date: + # When Stripe marks the subscription as ``past_due``, + # it means the usage of RTD service for the current period/month was not paid at all. + # At this point, we need to update our ``end_date`` to the last period the customer paid + # (which is the start date of the current ``past_due`` period --it could be the end date + # of the trial or the end date of the last paid period). + rtd_subscription.end_date = start_date + + klass = self.__class__.__name__ + change_reason = f'origin=stripe-subscription class={klass}' + + # Ensure that the organization is in the correct state. + # We want to always ensure the organization is never disabled + # if the subscription is valid. + organization = rtd_subscription.organization + if stripe_subscription.status == 'active' and organization.disabled: + log.warning( + 'Re-enabling organization with valid subscription.', + organization_slug=organization.slug, + stripe_subscription=rtd_subscription.id, + ) + organization.disabled = False + set_change_reason(organization, change_reason) + organization.save() + + set_change_reason(rtd_subscription, change_reason) + rtd_subscription.save() + return rtd_subscription + + +class PlanFeatureManager(models.Manager): + + def get_feature(self, obj, type): + """ + Get feature `type` for `obj`. + + :param obj: An organization or project instance. + :param type: The type of the feature (PlanFeature.TYPE_*). + :returns: A PlanFeature object or None. + """ + # Avoid circular imports. + from readthedocs.organizations.models import Organization + from readthedocs.projects.models import Project + + if isinstance(obj, Project): + organization = obj.organizations.first() + elif isinstance(obj, Organization): + organization = obj + else: + raise TypeError + + feature = self.filter( + feature_type=type, + plan__subscriptions__organization=organization, + ) + return feature.first() diff --git a/readthedocs/subscriptions/migrations/0001_squashed.py b/readthedocs/subscriptions/migrations/0001_squashed.py new file mode 100644 index 00000000000..407c9b54335 --- /dev/null +++ b/readthedocs/subscriptions/migrations/0001_squashed.py @@ -0,0 +1,104 @@ +# Generated by Django 2.2.24 on 2021-11-10 22:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import simple_history.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0005_historicalorganization_historicalteam'), + ('organizations', '0001_squashed'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Plan', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('pub_date', models.DateTimeField(auto_now_add=True, verbose_name='Publication date')), + ('modified_date', models.DateTimeField(auto_now=True, verbose_name='Modified date')), + ('name', models.CharField(max_length=100, verbose_name='Name')), + ('description', models.CharField(blank=True, max_length=255, null=True, verbose_name='Description')), + ('slug', models.SlugField(max_length=255, unique=True, verbose_name='Slug')), + ('stripe_id', models.CharField(max_length=100, verbose_name='Stripe ID')), + ('price', models.IntegerField(verbose_name='Price')), + ('trial', models.IntegerField(verbose_name='Trial')), + ('published', models.BooleanField(default=False, help_text="Warning: This will make this subscription available for users to buy. Don't add unless you're sure.", verbose_name='Published')), + ('for_organizations', models.ManyToManyField(blank=True, db_table='organizations_plan_for_organizations', related_name='available_plans', to='organizations.Organization', verbose_name='For organizations')), + ], + options={ + 'db_table': 'organizations_plan', + 'ordering': ('price',), + }, + ), + migrations.CreateModel( + name='HistoricalSubscription', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('extra_history_user_id', models.IntegerField(blank=True, db_index=True, null=True, verbose_name='ID')), + ('extra_history_user_username', models.CharField(db_index=True, max_length=150, null=True, verbose_name='username')), + ('locked', models.BooleanField(default=False, help_text='Locked plan for custom contracts', verbose_name='Locked plan')), + ('modified_date', models.DateTimeField(blank=True, editable=False, verbose_name='Modified date')), + ('stripe_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Stripe subscription ID')), + ('status', models.CharField(max_length=16, verbose_name='Subscription status')), + ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Start date')), + ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='End date')), + ('trial_end_date', models.DateTimeField(blank=True, null=True, verbose_name='Trial end date')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('organization', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='organizations.Organization', verbose_name='Organization')), + ('plan', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subscriptions.Plan', verbose_name='Plan')), + ('extra_history_browser', models.CharField(blank=True, max_length=250, null=True, verbose_name='Browser user-agent')), + ('extra_history_ip', models.CharField(blank=True, max_length=250, null=True, verbose_name='IP address')), + ], + options={ + 'verbose_name': 'historical subscription', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('locked', models.BooleanField(default=False, help_text='Locked plan for custom contracts', verbose_name='Locked plan')), + ('modified_date', models.DateTimeField(auto_now=True, verbose_name='Modified date')), + ('stripe_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='Stripe subscription ID')), + ('status', models.CharField(max_length=16, verbose_name='Subscription status')), + ('start_date', models.DateTimeField(blank=True, null=True, verbose_name='Start date')), + ('end_date', models.DateTimeField(blank=True, null=True, verbose_name='End date')), + ('trial_end_date', models.DateTimeField(blank=True, null=True, verbose_name='Trial end date')), + ('organization', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='organizations.Organization', verbose_name='Organization')), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='subscriptions.Plan', verbose_name='Plan')), + ], + options={ + 'db_table': 'organizations_subscription', + }, + ), + migrations.CreateModel( + name='PlanFeature', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('pub_date', models.DateTimeField(auto_now_add=True, verbose_name='Publication date')), + ('modified_date', models.DateTimeField(auto_now=True, verbose_name='Modified date')), + ('feature_type', models.CharField(choices=[('cname', 'Custom domain'), ('cdn', 'CDN public documentation'), ('ssl', 'Custom SSL configuration'), ('support', 'Support SLA'), ('private_docs', 'Private documentation'), ('embed_api', 'Embed content via API'), ('search_analytics', 'Search analytics'), ('pageviews_analytics', 'Pageview analytics'), ('concurrent_builds', 'Concurrent builds'), ('sso', 'Single sign on (SSO) with Google'), ('urls', 'Custom URLs'), ('audit-logs', 'Audit logs'), ('audit-pageviews', 'Record every page view')], max_length=32, verbose_name='Type')), + ('value', models.IntegerField(blank=True, null=True, verbose_name='Numeric value')), + ('description', models.CharField(blank=True, max_length=255, null=True, verbose_name='Description')), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='subscriptions.Plan')), + ], + options={ + 'db_table': 'organizations_planfeature', + 'unique_together': {('plan', 'feature_type')}, + }, + ), + ] diff --git a/readthedocs/subscriptions/migrations/__init__.py b/readthedocs/subscriptions/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/readthedocs/subscriptions/models.py b/readthedocs/subscriptions/models.py new file mode 100644 index 00000000000..9f94804d2fb --- /dev/null +++ b/readthedocs/subscriptions/models.py @@ -0,0 +1,239 @@ +from datetime import timedelta + +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import ugettext_lazy as _ + +from readthedocs.core.history import ExtraHistoricalRecords +from readthedocs.core.utils import slugify +from readthedocs.organizations.models import Organization +from readthedocsinc.subscriptions.managers import ( + PlanFeatureManager, + SubscriptionManager, +) + + +class Plan(models.Model): + + """Organization subscription plans.""" + + # Auto fields + pub_date = models.DateTimeField(_('Publication date'), auto_now_add=True) + modified_date = models.DateTimeField(_('Modified date'), auto_now=True) + + # Local + name = models.CharField(_('Name'), max_length=100) + description = models.CharField( + _('Description'), + max_length=255, + null=True, + blank=True, + ) + slug = models.SlugField(_('Slug'), max_length=255, unique=True) + stripe_id = models.CharField(_('Stripe ID'), max_length=100) + price = models.IntegerField(_('Price')) + trial = models.IntegerField(_('Trial')) + + # Foreign + for_organizations = models.ManyToManyField( + Organization, + verbose_name=_('For organizations'), + related_name='available_plans', + blank=True, + # Custom table is used, because these models were moved from an existing app. + db_table='organizations_plan_for_organizations', + ) + + published = models.BooleanField( + _('Published'), + default=False, + help_text="Warning: This will make this subscription available for users to buy. Don't add unless you're sure." # noqa + ) + + class Meta: + # Custom table is used, because these models were moved from an existing app. + db_table = 'organizations_plan' + ordering = ('price',) + + def get_absolute_url(self): + return reverse( + 'organization_plan_detail', + args=(self.organizations.all()[0].slug, self.slug), + ) + + def __str__(self): + return f"{self.name} ({self.stripe_id})" + + def save(self, *args, **kwargs): # pylint: disable=arguments-differ + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + + +class PlanFeature(models.Model): + + """ + Plan Feature. + + Useful for plan display, onboarding steps and other actions. + """ + + class Meta: + # Custom table is used, because these models were moved from an existing app. + db_table = 'organizations_planfeature' + unique_together = (('plan', 'feature_type'),) + + # Constants + UNLIMITED_VALUES = [None, -1] + """Values from `value` that represent an unlimited value.""" + + TYPE_CNAME = 'cname' + TYPE_CDN = 'cdn' + TYPE_SSL = 'ssl' + TYPE_SUPPORT = 'support' + + TYPE_PRIVATE_DOCS = 'private_docs' + TYPE_EMBED_API = 'embed_api' + TYPE_SEARCH_ANALYTICS = 'search_analytics' + TYPE_PAGEVIEW_ANALYTICS = 'pageviews_analytics' + TYPE_CONCURRENT_BUILDS = 'concurrent_builds' + TYPE_SSO = 'sso' + TYPE_CUSTOM_URL = 'urls' + TYPE_AUDIT_LOGS = 'audit-logs' + TYPE_AUDIT_PAGEVIEWS = 'audit-pageviews' + + TYPES = ( + (TYPE_CNAME, _('Custom domain')), + (TYPE_CDN, _('CDN public documentation')), + (TYPE_SSL, _('Custom SSL configuration')), + (TYPE_SUPPORT, _('Support SLA')), + (TYPE_PRIVATE_DOCS, _('Private documentation')), + (TYPE_EMBED_API, _('Embed content via API')), + (TYPE_SEARCH_ANALYTICS, _('Search analytics')), + (TYPE_PAGEVIEW_ANALYTICS, _('Pageview analytics')), + (TYPE_CONCURRENT_BUILDS, _('Concurrent builds')), + (TYPE_SSO, _('Single sign on (SSO) with Google')), + (TYPE_CUSTOM_URL, _('Custom URLs')), + (TYPE_AUDIT_LOGS, _('Audit logs')), + (TYPE_AUDIT_PAGEVIEWS, _('Record every page view')), + ) + + # Auto fields + pub_date = models.DateTimeField(_('Publication date'), auto_now_add=True) + modified_date = models.DateTimeField(_('Modified date'), auto_now=True) + + plan = models.ForeignKey( + Plan, + related_name='features', + on_delete=models.CASCADE, + ) + feature_type = models.CharField(_('Type'), max_length=32, choices=TYPES) + value = models.IntegerField(_('Numeric value'), null=True, blank=True) + description = models.CharField( + _('Description'), + max_length=255, + null=True, + blank=True, + ) + objects = PlanFeatureManager() + + def __str__(self): + return '{plan} feature: {feature}'.format( + plan=self.plan, + feature=self.get_feature_type_display(), + ) + + @property + def description_display(self): + return self.description or self.get_feature_type_display() + + +class Subscription(models.Model): + + """ + Organization subscription model. + + .. note:: + + ``status``, ``trial_end_date`` and maybe other fields are updated by + Stripe by hitting a webhook in our service hanlded by + ``StripeEventView``. + """ + + plan = models.ForeignKey( + Plan, + verbose_name=_('Plan'), + related_name='subscriptions', + on_delete=models.CASCADE, + ) + organization = models.OneToOneField( + Organization, + verbose_name=_('Organization'), + on_delete=models.CASCADE, + ) + locked = models.BooleanField( + _('Locked plan'), + default=False, + help_text=_('Locked plan for custom contracts'), + ) + modified_date = models.DateTimeField(_('Modified date'), auto_now=True) + + # These fields (stripe_id, status, start_date and end_date) are filled with + # the data retrieved from Stripe after the creation of the model instance. + stripe_id = models.CharField( + _('Stripe subscription ID'), + max_length=100, + blank=True, + null=True, + ) + status = models.CharField(_('Subscription status'), max_length=16) + start_date = models.DateTimeField(_('Start date'), blank=True, null=True) + end_date = models.DateTimeField( + _('End date'), + blank=True, + null=True, + ) + + trial_end_date = models.DateTimeField( + _('Trial end date'), + blank=True, + null=True, + ) + + objects = SubscriptionManager() + history = ExtraHistoricalRecords() + + class Meta: + # Custom table is used, because these models were moved from an existing app. + db_table = 'organizations_subscription' + + def __str__(self): + return '{org} subscription'.format(org=self.organization.name) + + @property + def is_trial_ended(self): + if self.trial_end_date: + return timezone.now() > self.trial_end_date + return False + + def get_status_display(self): + """ + Return the ``status`` to be presented to the user. + + Possible values: + https://stripe.com/docs/api/python#subscription_object-status + """ + return self.status.replace('_', ' ').title() + + def default_trial_end_date(self): + """Date of trial period end.""" + if self.plan is not None: + return ( + self.organization.pub_date + timedelta(days=self.plan.trial) + ) + + def save(self, *args, **kwargs): # pylint: disable=arguments-differ + if self.trial_end_date is None: + self.trial_end_date = self.default_trial_end_date() + super().save(*args, **kwargs) diff --git a/readthedocs/subscriptions/utils.py b/readthedocs/subscriptions/utils.py new file mode 100644 index 00000000000..5aaf5e2dc1d --- /dev/null +++ b/readthedocs/subscriptions/utils.py @@ -0,0 +1,51 @@ +import structlog + +import stripe +from stripe.error import InvalidRequestError + +log = structlog.get_logger(__name__) + + +def create_stripe_customer(organization): + """Create a stripe customer for organization.""" + stripe_customer = stripe.Customer.create( + email=organization.email, + description=organization.name, + ) + if organization.stripe_id: + log.warning( + 'Overriding existing stripe customer. ', + organization_slug=organization.slug, + previous_stripe_customer=organization.stripe_id, + stripe_customer=stripe_customer.id, + ) + organization.stripe_id = stripe_customer.id + organization.save() + return stripe_customer + + +def get_or_create_stripe_customer(organization): + """ + Retrieve the stripe customer from `organization.stripe_id` or create a new one. + + If `organization.stripe_id` is `None` or if the existing customer + doesn't exist in stripe, a new customer is created. + """ + log.bind( + organization_slug=organization.slug, + stripe_customer=organization.stripe_id, + ) + if not organization.stripe_id: + log.info('No stripe customer found, creating one.') + return create_stripe_customer(organization) + + try: + log.info('Retrieving existing stripe customer.') + stripe_customer = stripe.Customer.retrieve(organization.stripe_id) + return stripe_customer + except InvalidRequestError as e: + if e.code == 'resource_missing': + log.info('Invalid stripe customer, creating new one.') + return create_stripe_customer(organization) + log.exception('Error while retrieving stripe customer.') + raise From c826ebb7b871078a57492ea7ea172fa9a2938658 Mon Sep 17 00:00:00 2001 From: Santos Gallegos Date: Mon, 6 Dec 2021 13:14:10 -0500 Subject: [PATCH 2/2] Fix imports & Linter --- readthedocs/settings/base.py | 1 + readthedocs/subscriptions/admin.py | 7 ++++++- readthedocs/subscriptions/apps.py | 2 ++ readthedocs/subscriptions/managers.py | 13 +++++++++---- readthedocs/subscriptions/models.py | 10 +++++++--- readthedocs/subscriptions/utils.py | 6 ++++-- 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/readthedocs/settings/base.py b/readthedocs/settings/base.py index 9aa3f3c6683..b0fb86644d2 100644 --- a/readthedocs/settings/base.py +++ b/readthedocs/settings/base.py @@ -195,6 +195,7 @@ def INSTALLED_APPS(self): # noqa 'readthedocs.gold', 'readthedocs.payments', + 'readthedocs.subscriptions', 'readthedocs.notifications', 'readthedocs.integrations', 'readthedocs.analytics', diff --git a/readthedocs/subscriptions/admin.py b/readthedocs/subscriptions/admin.py index eb3b43bca81..5bbeee7fcb6 100644 --- a/readthedocs/subscriptions/admin.py +++ b/readthedocs/subscriptions/admin.py @@ -1,3 +1,5 @@ +"""Admin interface for subscription models.""" + from datetime import timedelta from django.contrib import admin @@ -6,7 +8,7 @@ from django.utils.html import format_html from readthedocs.core.history import ExtraSimpleHistoryAdmin -from readthedocsinc.subscriptions.models import Plan, PlanFeature, Subscription +from readthedocs.subscriptions.models import Plan, PlanFeature, Subscription class PlanFeatureInline(admin.TabularInline): @@ -34,6 +36,8 @@ class PlanFeatureAdmin(admin.ModelAdmin): class SubscriptionDateFilter(admin.SimpleListFilter): + """Filter for the status of the subscriptions related to their date.""" + title = 'subscription date' parameter_name = 'subscription_date' @@ -79,6 +83,7 @@ class SubscriptionAdmin(ExtraSimpleHistoryAdmin): readonly_fields = ('stripe_subscription',) search_fields = ('organization__name', 'stripe_id') + # pylint: disable=no-self-use def stripe_subscription(self, obj): if obj.stripe_id: return format_html( diff --git a/readthedocs/subscriptions/apps.py b/readthedocs/subscriptions/apps.py index bb0a84cb6a2..8881cf33ef4 100644 --- a/readthedocs/subscriptions/apps.py +++ b/readthedocs/subscriptions/apps.py @@ -1,3 +1,5 @@ +"""Subscriptions app.""" + from django.apps import AppConfig diff --git a/readthedocs/subscriptions/managers.py b/readthedocs/subscriptions/managers.py index 2713f18d1f9..61cc54665ae 100644 --- a/readthedocs/subscriptions/managers.py +++ b/readthedocs/subscriptions/managers.py @@ -1,12 +1,14 @@ -import structlog +"""Subscriptions managers.""" + from datetime import datetime +import structlog from django.conf import settings from django.db import models from django.utils import timezone from readthedocs.core.history import set_change_reason -from readthedocsinc.subscriptions.utils import get_or_create_stripe_customer +from readthedocs.subscriptions.utils import get_or_create_stripe_customer log = structlog.get_logger(__name__) @@ -30,7 +32,7 @@ def get_or_create_default_subscription(self, organization): if hasattr(organization, 'subscription'): return organization.subscription - from readthedocsinc.subscriptions.models import Plan + from readthedocs.subscriptions.models import Plan plan = Plan.objects.filter(slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG).first() # This should happen only on development. if not plan: @@ -125,7 +127,7 @@ def update_from_stripe(self, *, rtd_subscription, stripe_subscription): # Try our best to match a plan that is not custom. This mostly just # updates the UI now that we're using the Stripe Portal. A miss here # just won't update the UI, but this shouldn't happen for most users. - from readthedocsinc.subscriptions.models import Plan + from readthedocs.subscriptions.models import Plan try: plan = ( Plan.objects @@ -189,6 +191,9 @@ def update_from_stripe(self, *, rtd_subscription, stripe_subscription): class PlanFeatureManager(models.Manager): + """Model manager for PlanFeature.""" + + # pylint: disable=redefined-builtin def get_feature(self, obj, type): """ Get feature `type` for `obj`. diff --git a/readthedocs/subscriptions/models.py b/readthedocs/subscriptions/models.py index 9f94804d2fb..c4884a179e4 100644 --- a/readthedocs/subscriptions/models.py +++ b/readthedocs/subscriptions/models.py @@ -1,3 +1,5 @@ +"""Subscription models.""" + from datetime import timedelta from django.db import models @@ -8,7 +10,7 @@ from readthedocs.core.history import ExtraHistoricalRecords from readthedocs.core.utils import slugify from readthedocs.organizations.models import Organization -from readthedocsinc.subscriptions.managers import ( +from readthedocs.subscriptions.managers import ( PlanFeatureManager, SubscriptionManager, ) @@ -65,7 +67,8 @@ def get_absolute_url(self): def __str__(self): return f"{self.name} ({self.stripe_id})" - def save(self, *args, **kwargs): # pylint: disable=arguments-differ + # pylint: disable=signature-differs + def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) @@ -233,7 +236,8 @@ def default_trial_end_date(self): self.organization.pub_date + timedelta(days=self.plan.trial) ) - def save(self, *args, **kwargs): # pylint: disable=arguments-differ + # pylint: disable=signature-differs + def save(self, *args, **kwargs): if self.trial_end_date is None: self.trial_end_date = self.default_trial_end_date() super().save(*args, **kwargs) diff --git a/readthedocs/subscriptions/utils.py b/readthedocs/subscriptions/utils.py index 5aaf5e2dc1d..094183e0e3c 100644 --- a/readthedocs/subscriptions/utils.py +++ b/readthedocs/subscriptions/utils.py @@ -1,3 +1,5 @@ +"""Utilities to interact with subscriptions and stripe.""" + import structlog import stripe @@ -43,8 +45,8 @@ def get_or_create_stripe_customer(organization): log.info('Retrieving existing stripe customer.') stripe_customer = stripe.Customer.retrieve(organization.stripe_id) return stripe_customer - except InvalidRequestError as e: - if e.code == 'resource_missing': + except InvalidRequestError as exc: + if exc.code == 'resource_missing': log.info('Invalid stripe customer, creating new one.') return create_stripe_customer(organization) log.exception('Error while retrieving stripe customer.')