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