Repository: mdn/kuma

Issue:
T - Provide subscribers with a static customer number that is incremented upon each new subscription.
**Description**

Upon each new subscription signup we need to store a customer number that increments +1 over the previous customer's (most-recent) number. 

This number needs to be static (and not dynamically computed based on a count of customers) for a few reasons:
- the customer should be able to rely on their number as her own.
- X customer's unsubscribe or deletion should not affect other customer Y's number. 
- if we ever gave rewards/prizes for customer no. 100, we would not want the number to be dynamic, as that might disastrous

**Acceptance Criteria**
- [ ] upon customer subscription, a number is calculated and stored in our database
- [ ] this customer number is immutable and unaffected by other customers' subscription cancellations or acct deletions



Discussion:
One thing to think about is that this number will be "An increment of when you subscribed. Any subscription."
If in the future we have something like tiers ("Gold - $100/month, Silver $25/momth, Bronze - $5/month") then that'd be different subscriptions as far as the `UserSubscription` model is concerned but his number here would be across all and any. 

Shall we, as part of this start it off based on the existing 43 users that have a stripe_customer_id. ? So, whoever signs up first after we launch this, they'll become subscriber number `User.objects.exclude(stripe_customer_id='').count() + 1`
Oh and not just that. Of our 43 existing stripe customers, how do we figure out what number they should get? I think we could write a script that goes through the 43 existing customers and for each one, query the Stripe API to see when their subscription started and then use that. 
Actually, I dug into this a bit more. The reason I care is that I don't think it's fair that the first new subscriber (once this issue turns into a PR that goes into `master` that goes into production) becomes subscriber number 1. We should respect the roughly 27 (at the time of writing) Stripe customers that have active subscriptions. Or, perhaps respect all the 43 users that have a `stripe_customer_id`. 

I think we ought to write 2 things:

1. Whenever you become a subscriber, if you didn't already have it in your user profile, you get a subscriber increment number. 
2. A script or "migration of sorts" that takes care of the handful of existing customers. 

What we could do is write a migration that, after the `subscriber_number` field on the `User` model is introduced, looks through the list of users that have a non-empty `stripe_customer_id` and then checks each and everyone against Stripe if they have an active subscription. 

Or, even simpler! If we don't care about the *order* of these 27 active subscribers or the order of all 43 users with a `stripe_customer_id` we can simply do something like this:

```python
number = 0
for user in User.objects.exclude(stripe_customer_id='').filter(subscriber_number__isnull=True):
    user.subscriber_number = number + 1
    user.save()
    number += 1
```

Thoughts on this @atopal @Gregoor ?
Sounds sensible to me, Peter :+1:

Note for the future that we should probably set that subscriber_number within SQL aka `subscriber_number = SELECT MAX(subscriber_number) + 1 FROM auth_users`, to prevent race conditions.
> prevent race conditions.

I didn't think of that! Good idea. 
Let’s go with those who have a stripe customer id, but order is irrelevant.

Resolving patch:
diff --git a/kuma/users/admin.py b/kuma/users/admin.py
--- a/kuma/users/admin.py
+++ b/kuma/users/admin.py
@@ -18,6 +18,23 @@ class UserBanAdmin(admin.ModelAdmin):
     search_fields = ("user__username", "reason", "by__username")
 
 
+class IsStripeCustomer(admin.SimpleListFilter):
+    title = "is Stripe customer"
+    parameter_name = "is_stripe_customer"
+
+    def lookups(self, request, model_admin):
+        return (
+            (True, "Yes"),
+            (False, "No"),
+        )
+
+    def queryset(self, request, queryset):
+        if self.value():
+            return queryset.exclude(stripe_customer_id="")
+        else:
+            return queryset.filter(stripe_customer_id="")
+
+
 @admin.register(User)
 class UserAdmin(BaseUserAdmin):
     """
@@ -25,6 +42,14 @@ class UserAdmin(BaseUserAdmin):
     add a filter on the field too
     """
 
+    fieldsets = BaseUserAdmin.fieldsets + (
+        ("Subscription", {"fields": ("stripe_customer_id", "subscriber_number")}),
+    )
+    readonly_fields = BaseUserAdmin.readonly_fields + (
+        "stripe_customer_id",
+        "subscriber_number",
+    )
+
     list_display = (
         "username",
         "fullname",
@@ -34,7 +59,14 @@ class UserAdmin(BaseUserAdmin):
         "is_staff",
         "is_active",
     )
-    list_filter = ("is_staff", "is_superuser", "is_active", "date_joined", "groups")
+    list_filter = (
+        "is_staff",
+        "is_superuser",
+        "is_active",
+        "date_joined",
+        "groups",
+        IsStripeCustomer,
+    )
     ordering = ("-date_joined",)
     search_fields = (
         "username",
diff --git a/kuma/users/migrations/0018_user_subscriber_number.py b/kuma/users/migrations/0018_user_subscriber_number.py
new file mode 100644
--- /dev/null
+++ b/kuma/users/migrations/0018_user_subscriber_number.py
@@ -0,0 +1,30 @@
+# Generated by Django 2.2.11 on 2020-03-30 07:17
+
+from django.db import migrations, models
+
+
+def set_initial_subscriber_number(apps, schema_editor):
+    User = apps.get_model("users", "User")
+    number = 1
+    for user in User.objects.exclude(
+        stripe_customer_id="", subscriber_number__isnull=True
+    ):
+        user.subscriber_number = number
+        user.save()
+        number += 1
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("users", "0017_usersubscription"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="user",
+            name="subscriber_number",
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
+        migrations.RunPython(set_initial_subscriber_number),
+    ]
diff --git a/kuma/users/models.py b/kuma/users/models.py
--- a/kuma/users/models.py
+++ b/kuma/users/models.py
@@ -5,7 +5,10 @@
 from django.contrib.auth.models import AbstractUser
 from django.contrib.auth.tokens import default_token_generator
 from django.core import validators
-from django.db import models
+from django.core.cache import cache
+from django.db import models, transaction
+from django.db.models import Max
+from django.dispatch import receiver
 from django.utils import timezone
 from django.utils.encoding import force_bytes
 from django.utils.functional import cached_property
@@ -158,6 +161,8 @@ class User(AbstractUser):
     )
     stripe_customer_id = models.CharField(max_length=255, blank=True)
 
+    subscriber_number = models.PositiveIntegerField(blank=True, null=True)
+
     class Meta:
         db_table = "auth_user"
 
@@ -193,6 +198,20 @@ def get_recovery_url(self):
         link = reverse("users.recover", kwargs={"token": token, "uidb64": uidb64})
         return link
 
+    def set_next_subscriber_number_and_save(self):
+        assert not self.subscriber_number, "already set"
+        lock_key = f"set_next_subscriber_number_and_save"
+        # By locking "globally", we get to be certain that our query to get
+        # the current highest `subscriber_number`, gets done alone.
+        with cache.lock(lock_key):
+            highest_number = (
+                User.objects.filter(subscriber_number__isnull=True).aggregate(
+                    number=Max("subscriber_number")
+                )["number"]
+                or 0
+            )
+            User.objects.filter(id=self.id).update(subscriber_number=highest_number + 1)
+
 
 class UserSubscription(models.Model):
     user = models.ForeignKey(User, on_delete=models.CASCADE)
@@ -208,6 +227,12 @@ def __str__(self):
         )
 
     @classmethod
+    # The reason we make this (class) method transaction atomic is because this
+    # use of `update_or_create` will actually trigger a `post_save` signal
+    # on the `User` model which will set the `User.subscriber_number` if needed.
+    # So, this way we're pre-emptively making sure these two things are atomically
+    # connected.
+    @transaction.atomic()
     def set_active(cls, user, stripe_subscription_id):
         cls.objects.update_or_create(
             stripe_subscription_id=stripe_subscription_id,
@@ -222,3 +247,9 @@ def set_canceled(cls, user, stripe_subscription_id):
             user=user,
             defaults={"canceled": timezone.now(), "updated": timezone.now()},
         )
+
+
+@receiver(models.signals.post_save, sender=UserSubscription)
+def set_user_subscriber_number(sender, instance, **kwargs):
+    if not instance.canceled and not instance.user.subscriber_number:
+        instance.user.set_next_subscriber_number_and_save()


Test patch:
diff --git a/kuma/users/tests/test_models.py b/kuma/users/tests/test_models.py
--- a/kuma/users/tests/test_models.py
+++ b/kuma/users/tests/test_models.py
@@ -177,3 +177,27 @@ def test_user_subscription_set_canceled(wiki_user):
         UserSubscription.objects.filter(user=wiki_user, canceled__isnull=False).count()
         == 2
     )
+
+
+def test_user_subscription_and_subscriber_number(wiki_user):
+    """This test checks the relationship between UserSubscription.set_active,
+    UserSubscription.set_canceled, and User.subscriber_number."""
+    assert wiki_user.subscriber_number is None
+    UserSubscription.set_active(wiki_user, "abc123")
+    wiki_user.refresh_from_db()
+    assert wiki_user.subscriber_number == 1
+
+    # Hit it again, and the subscriber_number shouldn't go up
+    UserSubscription.set_active(wiki_user, "abc123")
+    wiki_user.refresh_from_db()
+    assert wiki_user.subscriber_number == 1
+
+    # And not go down if you cancel
+    UserSubscription.set_canceled(wiki_user, "abc123")
+    wiki_user.refresh_from_db()
+    assert wiki_user.subscriber_number == 1
+
+    # And creating a brand new subscription doesn't change it either
+    UserSubscription.set_active(wiki_user, "xyz789")
+    wiki_user.refresh_from_db()
+    assert wiki_user.subscriber_number == 1