Browse Source

Send email when event quota is near on hosted glitchtip

David Burke 4 years ago
parent
commit
d8993fc6cd

+ 30 - 0
djstripe_ext/email.py

@@ -0,0 +1,30 @@
+from django.conf import settings
+from django.core.mail import EmailMultiAlternatives
+from django.template.loader import render_to_string
+
+
+def send_email_warn_quota(
+    organization, subscription, event_count: int, plan_event_count: int
+):
+    template_html = "near-quota-drip.html"
+    template_txt = "near-quota-drip.txt"
+
+    subject = f"Nearing event quota for {organization.name}"
+
+    base_url = settings.GLITCHTIP_DOMAIN.geturl()
+    subscription_link = f"{base_url}/{organization.slug}/settings/subscription"
+
+    context = {
+        "organization": organization.name,
+        "plan_event_count": plan_event_count,
+        "event_count": event_count,
+        "subscription_link": subscription_link,
+    }
+
+    text_content = render_to_string(template_txt, context)
+    html_content = render_to_string(template_html, context)
+
+    to = [organization.email]
+    msg = EmailMultiAlternatives(subject, text_content, to=to)
+    msg.attach_alternative(html_content, "text/html")
+    msg.send()

+ 24 - 0
djstripe_ext/migrations/0001_initial.py

@@ -0,0 +1,24 @@
+# Generated by Django 3.1.7 on 2021-03-12 19:09
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('djstripe', '0007_2_4'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SubscriptionQuotaWarning',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('notice_last_sent', models.DateTimeField(auto_now=True)),
+                ('subscription', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='djstripe.subscription')),
+            ],
+        ),
+    ]

+ 0 - 0
djstripe_ext/migrations/__init__.py


+ 10 - 0
djstripe_ext/models.py

@@ -0,0 +1,10 @@
+from django.db import models
+
+
+class SubscriptionQuotaWarning(models.Model):
+    """ Track when quota warnings are sent out """
+
+    subscription = models.OneToOneField(
+        "djstripe.Subscription", on_delete=models.CASCADE
+    )
+    notice_last_sent = models.DateTimeField(auto_now=True)

+ 79 - 0
djstripe_ext/tasks.py

@@ -0,0 +1,79 @@
+from django.conf import settings
+from django.db.models import Count, Q, F, Subquery, OuterRef, IntegerField
+from django.db.models.functions import Cast
+from django.contrib.postgres.fields.jsonb import KeyTextTransform
+from celery import shared_task
+from organizations_ext.models import Organization
+from projects.models import Project
+from .models import SubscriptionQuotaWarning
+from .email import send_email_warn_quota
+
+
+@shared_task
+def warn_organization_throttle():
+    """ Warn user about approaching 80% of allotted events """
+    if not settings.BILLING_ENABLED:
+        return
+
+    queryset = Organization.objects.filter(
+        djstripe_customers__subscriptions__status="active",
+    ).filter(
+        Q(djstripe_customers__subscriptions__subscriptionquotawarning=None)
+        | Q(
+            djstripe_customers__subscriptions__subscriptionquotawarning__notice_last_sent__lt=F(
+                "djstripe_customers__subscriptions__current_period_start"
+            ),
+        )
+    )
+
+    projects = Project.objects.filter(organization=OuterRef("pk")).values(
+        "organization"
+    )
+    total_issue_events = projects.annotate(
+        total=Count(
+            "issue__event",
+            filter=Q(
+                issue__event__created__gte=OuterRef(
+                    "djstripe_customers__subscriptions__current_period_start"
+                )
+            ),
+        )
+    ).values("total")
+    total_transaction_events = projects.annotate(
+        total=Count(
+            "transactionevent",
+            filter=Q(
+                transactionevent__created__gte=OuterRef(
+                    "djstripe_customers__subscriptions__current_period_start"
+                )
+            ),
+        )
+    ).values("total")
+
+    queryset = queryset.annotate(
+        event_count=Subquery(total_issue_events) + Subquery(total_transaction_events),
+        plan_event_count=Cast(
+            KeyTextTransform(
+                "events", "djstripe_customers__subscriptions__plan__product__metadata"
+            ),
+            output_field=IntegerField(),
+        ),
+    )
+
+    # 80% to 100% of event quota
+    queryset = queryset.filter(
+        event_count__gte=F("plan_event_count") * 0.80,
+        event_count__lte=F("plan_event_count"),
+    )
+
+    for org in queryset:
+        subscription = org.djstripe_customers.first().subscription
+        send_email_warn_quota(
+            org, subscription, org.event_count, org.plan_event_count,
+        )
+        warning, created = SubscriptionQuotaWarning.objects.get_or_create(
+            subscription=subscription
+        )
+        if not created:
+            warning.save()
+

File diff suppressed because it is too large
+ 1 - 0
djstripe_ext/templates/near-quota-drip.html


+ 5 - 0
djstripe_ext/templates/near-quota-drip.txt

@@ -0,0 +1,5 @@
+You are near your event quota
+Organization: {{ organization }}
+Your plan allows for {{ plan_event_count }} events per month, and your projects have used {{ event_count }}.
+If you reach the limit, you will not receive events in GlitchTip until the monthly cycle resets. You can avoid this by upgrading your plan or by adjusting the sample rate in your SDK. 
+Manage your subscription: {{ subscription_link }}

+ 0 - 0
djstripe_ext/tests/__init__.py


+ 58 - 0
djstripe_ext/tests/test_tasks.py

@@ -0,0 +1,58 @@
+from datetime import timedelta
+from django.core import mail
+from django.test import TestCase
+from django.utils import timezone
+from model_bakery import baker
+from freezegun import freeze_time
+from glitchtip import test_utils  # pylint: disable=unused-import
+from ..tasks import warn_organization_throttle
+
+
+class OrganizationWarnThrottlingTestCase(TestCase):
+    def test_warn_organization_throttle(self):
+        user = baker.make("users.User")
+        project = baker.make(
+            "projects.Project", organization__owner__organization_user__user=user,
+        )
+        plan = baker.make(
+            "djstripe.Plan", active=True, amount=0, product__metadata={"events": "10"},
+        )
+
+        project2 = baker.make(
+            "projects.Project",
+            organization__owner__organization_user__user=user,
+            organization__djstripe_customers__subscriptions__plan=plan,
+        )
+
+        with freeze_time(timezone.datetime(2000, 1, 1)):
+            subscription = baker.make(
+                "djstripe.Subscription",
+                customer__subscriber=project.organization,
+                livemode=False,
+                plan=plan,
+                status="active",
+            )
+            subscription.current_period_end = (
+                subscription.current_period_start + timedelta(days=30)
+            )
+            subscription.save()
+            baker.make("events.Event", issue__project=project, _quantity=9)
+            warn_organization_throttle()
+            self.assertEqual(len(mail.outbox), 1)
+            warn_organization_throttle()
+            self.assertEqual(len(mail.outbox), 1)
+
+        with freeze_time(timezone.datetime(2000, 2, 2)):
+            subscription.current_period_start = timezone.make_aware(
+                timezone.datetime(2000, 2, 1)
+            )
+            subscription.current_period_end = (
+                subscription.current_period_start + timedelta(days=30)
+            )
+            subscription.save()
+            warn_organization_throttle()
+            self.assertEqual(len(mail.outbox), 1)
+
+            baker.make("events.Event", issue__project=project, _quantity=9)
+            warn_organization_throttle()
+            self.assertEqual(len(mail.outbox), 2)

+ 0 - 0
djstripe_ext/tests.py → djstripe_ext/tests/tests.py


Some files were not shown because too many files changed in this diff