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,
+
+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 }}.
+
+Sincerely,
+The {% site_name %} Team
+{% endblock %}
+
+{% block unsub %}
+
+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