From 945a66ab0a5d68841c9ed9a55a795e9a0cdbfc45 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?P=C4=93teris=20Caune?= <cuu508@gmail.com>
Date: Wed, 13 Mar 2019 00:38:34 +0200
Subject: [PATCH] Management command for sending inactive account notifications

---
 CHANGELOG.md                                  |  1 +
 hc/accounts/admin.py                          |  1 +
 hc/accounts/management/commands/pruneusers.py | 24 +++++--
 .../commands/senddeletionnotices.py           | 63 +++++++++++++++++++
 .../0027_profile_deletion_notice_date.py      | 18 ++++++
 hc/accounts/models.py                         |  1 +
 hc/api/migrations/0058_auto_20190312_1716.py  | 18 ++++++
 hc/lib/emails.py                              |  4 ++
 hc/settings.py                                |  1 +
 .../emails/deletion-notice-body-html.html     | 20 ++++++
 .../emails/deletion-notice-body-text.html     | 14 +++++
 templates/emails/deletion-notice-subject.html |  1 +
 12 files changed, 161 insertions(+), 5 deletions(-)
 create mode 100644 hc/accounts/management/commands/senddeletionnotices.py
 create mode 100644 hc/accounts/migrations/0027_profile_deletion_notice_date.py
 create mode 100644 hc/api/migrations/0058_auto_20190312_1716.py
 create mode 100644 templates/emails/deletion-notice-body-html.html
 create mode 100644 templates/emails/deletion-notice-body-text.html
 create mode 100644 templates/emails/deletion-notice-subject.html

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7c49c95..983edbb7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
 - Add maxlength attribute to HTML input=text elements
 - Improved logic for displaying job execution times in log (#219)
 - Add Matrix integration
+- Add a management command for sending inactive account notifications
 
 ### Bug Fixes
 - Fix refreshing of the checks page filtered by tags (#221)
diff --git a/hc/accounts/admin.py b/hc/accounts/admin.py
index 4bbd1f83..8aaf0de8 100644
--- a/hc/accounts/admin.py
+++ b/hc/accounts/admin.py
@@ -22,6 +22,7 @@ class ProfileFieldset(Fieldset):
     name = "User Profile"
     fields = ("email", "current_project", "reports_allowed",
               "next_report_date", "nag_period", "next_nag_date",
+              "deletion_notice_date",
               "token", "sort")
 
 
diff --git a/hc/accounts/management/commands/pruneusers.py b/hc/accounts/management/commands/pruneusers.py
index 5fe5e5ea..be6669b6 100644
--- a/hc/accounts/management/commands/pruneusers.py
+++ b/hc/accounts/management/commands/pruneusers.py
@@ -2,8 +2,9 @@ from datetime import timedelta
 
 from django.contrib.auth.models import User
 from django.core.management.base import BaseCommand
-from django.db.models import Count
-from django.utils import timezone
+from django.db.models import Count, F
+from django.utils.timezone import now
+from hc.accounts.models import Profile
 
 
 class Command(BaseCommand):
@@ -18,12 +19,25 @@ class Command(BaseCommand):
     """
 
     def handle(self, *args, **options):
-        cutoff = timezone.now() - timedelta(days=30)
+        month_ago = now() - timedelta(days=30)
 
         # Old accounts, never logged in, no team memberships
         q = User.objects.order_by("id")
         q = q.annotate(n_teams=Count("memberships"))
-        q = q.filter(date_joined__lt=cutoff, last_login=None, n_teams=0)
+        q = q.filter(date_joined__lt=month_ago, last_login=None, n_teams=0)
 
         n, summary = q.delete()
-        return "Done! Pruned %d user accounts." % summary.get("auth.User", 0)
+        count = summary.get("auth.User", 0)
+        self.stdout.write("Pruned %d never-logged-in user accounts." % count)
+
+        # Profiles scheduled for deletion
+        q = Profile.objects.order_by("id")
+        q = q.filter(deletion_notice_date__lt=month_ago)
+        # Exclude users who have logged in after receiving deletion notice
+        q = q.exclude(user__last_login__gt=F("deletion_notice_date"))
+
+        for profile in q:
+            self.stdout.write("Deleting inactive %s" % profile.user.email)
+            profile.user.delete()
+
+        return "Done!"
diff --git a/hc/accounts/management/commands/senddeletionnotices.py b/hc/accounts/management/commands/senddeletionnotices.py
new file mode 100644
index 00000000..cd344ec1
--- /dev/null
+++ b/hc/accounts/management/commands/senddeletionnotices.py
@@ -0,0 +1,63 @@
+from datetime import timedelta
+import time
+
+from django.conf import settings
+from django.core.management.base import BaseCommand
+from django.utils.timezone import now
+from hc.accounts.models import Profile, Member
+from hc.api.models import Ping
+from hc.lib import emails
+
+
+class Command(BaseCommand):
+    help = """Send deletion notices to inactive user accounts.
+
+    Conditions for sending the notice:
+        - deletion notice has not been sent recently
+        - last login more than a year ago
+        - none of the owned projects has invited team members
+
+    """
+
+    def handle(self, *args, **options):
+        year_ago = now() - timedelta(days=365)
+
+        q = Profile.objects.order_by("id")
+        # Exclude accounts with logins in the last year_ago
+        q = q.exclude(user__last_login__gt=year_ago)
+        # Exclude accounts less than a year_ago old
+        q = q.exclude(user__date_joined__gt=year_ago)
+        # Exclude accounts with the deletion notice already sent
+        q = q.exclude(deletion_notice_date__gt=year_ago)
+        # Exclude paid accounts
+        q = q.exclude(sms_limit__gt=0)
+
+        sent = 0
+        for profile in q:
+            members = Member.objects.filter(project__owner_id=profile.user_id)
+            if members.exists():
+                print("Skipping %s, has team members" % profile)
+                continue
+
+            pings = Ping.objects
+            pings = pings.filter(owner__project__owner_id=profile.user_id)
+            pings = pings.filter(created__gt=year_ago)
+            if pings.exists():
+                print("Skipping %s, has pings in last year" % profile)
+                continue
+
+            self.stdout.write("Sending notice to %s" % profile.user.email)
+
+            profile.deletion_notice_date = now()
+            profile.save()
+
+            ctx = {
+                "email": profile.user.email,
+                "support_email": settings.SUPPORT_EMAIL
+            }
+            emails.deletion_notice(profile.user.email, ctx)
+            # Throttle so we don't send too many emails at once:
+            time.sleep(1)
+            sent += 1
+
+        return "Done! Sent %d notices" % sent
diff --git a/hc/accounts/migrations/0027_profile_deletion_notice_date.py b/hc/accounts/migrations/0027_profile_deletion_notice_date.py
new file mode 100644
index 00000000..939a9b0e
--- /dev/null
+++ b/hc/accounts/migrations/0027_profile_deletion_notice_date.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.7 on 2019-03-12 17:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('accounts', '0026_auto_20190204_2042'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='profile',
+            name='deletion_notice_date',
+            field=models.DateTimeField(blank=True, null=True),
+        ),
+    ]
diff --git a/hc/accounts/models.py b/hc/accounts/models.py
index 59e80b93..03da63e1 100644
--- a/hc/accounts/models.py
+++ b/hc/accounts/models.py
@@ -56,6 +56,7 @@ class Profile(models.Model):
     sms_sent = models.IntegerField(default=0)
     team_limit = models.IntegerField(default=2)
     sort = models.CharField(max_length=20, default="created")
+    deletion_notice_date = models.DateTimeField(null=True, blank=True)
 
     objects = ProfileManager()
 
diff --git a/hc/api/migrations/0058_auto_20190312_1716.py b/hc/api/migrations/0058_auto_20190312_1716.py
new file mode 100644
index 00000000..dbd86a39
--- /dev/null
+++ b/hc/api/migrations/0058_auto_20190312_1716.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.7 on 2019-03-12 17:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0057_auto_20190118_1319'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='channel',
+            name='kind',
+            field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk'), ('trello', 'Trello'), ('matrix', 'Matrix')], max_length=20),
+        ),
+    ]
diff --git a/hc/lib/emails.py b/hc/lib/emails.py
index 70efcc47..63d5f6e1 100644
--- a/hc/lib/emails.py
+++ b/hc/lib/emails.py
@@ -70,3 +70,7 @@ def invoice(to, ctx, filename, pdf_data):
     msg.attach_alternative(html, "text/html")
     msg.attach(filename, pdf_data, "application/pdf")
     msg.send()
+
+
+def deletion_notice(to, ctx, headers={}):
+    send("deletion-notice", to, ctx, headers)
diff --git a/hc/settings.py b/hc/settings.py
index 59c146d5..daf97404 100644
--- a/hc/settings.py
+++ b/hc/settings.py
@@ -31,6 +31,7 @@ SECRET_KEY = os.getenv("SECRET_KEY", "---")
 DEBUG = envbool("DEBUG", "True")
 ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "*").split(",")
 DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "healthchecks@example.org")
+SUPPORT_EMAIL = os.getenv("SUPPORT_EMAIL")
 USE_PAYMENTS = envbool("USE_PAYMENTS", "False")
 REGISTRATION_OPEN = envbool("REGISTRATION_OPEN", "True")
 
diff --git a/templates/emails/deletion-notice-body-html.html b/templates/emails/deletion-notice-body-html.html
new file mode 100644
index 00000000..777079de
--- /dev/null
+++ b/templates/emails/deletion-notice-body-html.html
@@ -0,0 +1,20 @@
+{% extends "emails/base.html" %}
+{% load hc_extras %}
+
+{% block content %}
+Hello,<br />
+
+We’re sending this email to notify you that your {% site_name %} account, registered to {{ email }} has been inactive for 1 year or more.  If you no longer wish to keep your {% site_name %} account active then we will make sure that your account is closed and any data associated with your account is permanently deleted from our systems.<br /><br />
+
+If you wish to keep your account, simply log in within 30 days. If you continue to be inactive, <strong>your account will be permanently deleted after the 30 day period</strong>.<br /><br />
+
+If you have issues logging in, or have any questions, please reach out to us at {{ support_email }}.<br /><br />
+
+Sincerely,<br />
+The {% site_name %} Team
+{% endblock %}
+
+{% block unsub %}
+<br />
+This is a one-time message we're sending out to notify you about your account closure.
+{% endblock %}
\ No newline at end of file
diff --git a/templates/emails/deletion-notice-body-text.html b/templates/emails/deletion-notice-body-text.html
new file mode 100644
index 00000000..b9508eb2
--- /dev/null
+++ b/templates/emails/deletion-notice-body-text.html
@@ -0,0 +1,14 @@
+{% load hc_extras %}
+Hello,
+
+We’re sending this email to notify you that your {% site_name %} account, registered to {{ email }} has been inactive for 1 year or more.  If you no longer wish to keep your {% site_name %} account active then we will make sure that your account is closed and any data associated with your account is permanently deleted from our systems.
+
+If you wish to keep your account, simply log in within 30 days. If you continue to be inactive, your account will be permanently deleted after the 30 day period.
+
+If you have issues logging in, or have any questions, please reach out to us at {{ support_email }}.
+
+This is a one-time message we're sending out to notify you about your account closure.
+
+--
+Sincerely,
+The {% site_name %} Team
diff --git a/templates/emails/deletion-notice-subject.html b/templates/emails/deletion-notice-subject.html
new file mode 100644
index 00000000..05159754
--- /dev/null
+++ b/templates/emails/deletion-notice-subject.html
@@ -0,0 +1 @@
+Inactive Account Notification
\ No newline at end of file