diff --git a/requirements.txt b/requirements.txt index 4badcd7..1791a64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,8 @@ colour-runner==0.0.4 coverage==4.0.3 dj-database-url==0.4.1 django==1.8.12 +factory-boy==2.6.1 flake8==2.5.4 flake8-import-order==0.7 +incuna-pigeon==0.1.0 +python-dateutil==2.5.2 diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 0000000..b76291a --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,12 @@ +import factory + +from .models import User + + +class UserFactory(factory.DjangoModelFactory): + name = factory.Sequence('User {}'.format) + username = factory.Sequence('username{}'.format) + email = factory.Sequence('email{}@incuna.com'.format) + + class Meta: + model = User diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000..390824d --- /dev/null +++ b/tests/models.py @@ -0,0 +1,18 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + +from user_deletion.managers import UserManagerMixin + + +class UserManager(UserManagerMixin, models.Manager): + pass + + +class User(AbstractUser): + name = models.CharField(max_length=255) + notified = models.BooleanField(default=False) + + objects = UserManager() + + def __str__(self): + return self.name diff --git a/tests/run.py b/tests/run.py index 9f086ba..85570f5 100644 --- a/tests/run.py +++ b/tests/run.py @@ -16,8 +16,19 @@ default='sqlite://{}/user_deletion.db'.format(BASEDIR), ), }, - INSTALLED_APPS=('user_deletion',), + INSTALLED_APPS=( + 'tests', + 'user_deletion', + + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sites', + ), MIDDLEWARE_CLASSES=(), + + AUTH_USER_MODEL='tests.User', + SITE_ID=1, + DEFAULT_FROM_EMAIL='from@example.com', ) diff --git a/tests/test_management_commands.py b/tests/test_management_commands.py new file mode 100644 index 0000000..c1f38f3 --- /dev/null +++ b/tests/test_management_commands.py @@ -0,0 +1,43 @@ +from datetime import datetime +from io import StringIO + +from dateutil.relativedelta import relativedelta +from django.core import mail +from django.core.management import call_command +from django.test import TestCase + +from .factories import UserFactory + + +class TestUserNotifyManagementCommand(TestCase): + def setUp(self): + self.stdout = StringIO() + + def test_no_users(self): + call_command('notify_users') + self.assertFalse(len(mail.outbox)) + + def test_inactive_users(self): + year_ago = datetime.now() + relativedelta(months=-12) + UserFactory.create(last_login=year_ago) + + call_command('notify_users') + + self.assertEqual(len(mail.outbox), 1) + + def test_email(self): + year_ago = datetime.now() + relativedelta(months=-12) + UserFactory.create(last_login=year_ago) + call_command('notify_users') + + email = mail.outbox[0] + self.assertEqual(email.subject, 'Re-activate your account') + self.assertIn('We have noticed', email.body) + + def test_active_users(self): + # user was notified before + year_ago = datetime.now() + relativedelta(months=-13) + UserFactory.create(last_login=year_ago, notified=True) + + call_command('notify_users') + self.assertFalse(len(mail.outbox)) diff --git a/tests/test_managers.py b/tests/test_managers.py new file mode 100644 index 0000000..e22db2d --- /dev/null +++ b/tests/test_managers.py @@ -0,0 +1,20 @@ +from dateutil.relativedelta import relativedelta +from django.apps import apps +from django.test import TestCase +from django.utils import timezone + +from .factories import UserFactory +from .models import User + + +user_deletion_config = apps.get_app_config('user_deletion') + + +class TestUserDeletionManager(TestCase): + def test_users_to_notify(self): + last_login = timezone.now() - relativedelta(months=user_deletion_config.MONTHS) + user = UserFactory.create(last_login=last_login) + + users = User.objects.users_to_notify() + + self.assertCountEqual(users, [user]) diff --git a/user_deletion/__init__.py b/user_deletion/__init__.py index e69de29..76e2af5 100644 --- a/user_deletion/__init__.py +++ b/user_deletion/__init__.py @@ -0,0 +1 @@ +default_app_config = 'user_deletion.apps.UserDeletionConfig' diff --git a/user_deletion/apps.py b/user_deletion/apps.py new file mode 100644 index 0000000..3be77e3 --- /dev/null +++ b/user_deletion/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class UserDeletionConfig(AppConfig): + name = 'user_deletion' + + MONTHS = 12 diff --git a/user_deletion/management/commands/__init__.py b/user_deletion/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/user_deletion/management/commands/delete_users.py b/user_deletion/management/commands/delete_users.py new file mode 100644 index 0000000..e69de29 diff --git a/user_deletion/management/commands/notify_users.py b/user_deletion/management/commands/notify_users.py new file mode 100644 index 0000000..8365206 --- /dev/null +++ b/user_deletion/management/commands/notify_users.py @@ -0,0 +1,17 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand +from django.utils import translation + +from ...notifications import DeletionNotification + +User = get_user_model() + + +class Command(BaseCommand): + def handle(self, *args, **options): + translation.activate(settings.LANGUAGE_CODE) + users = User.objects.users_to_notify() + site = Site.objects.get_current() + DeletionNotification(user=None, site=site, users=users).notify() diff --git a/user_deletion/managers.py b/user_deletion/managers.py new file mode 100644 index 0000000..e9d3826 --- /dev/null +++ b/user_deletion/managers.py @@ -0,0 +1,13 @@ +from dateutil.relativedelta import relativedelta +from django.apps import apps +from django.utils import timezone + + +user_deletion_config = apps.get_app_config('user_deletion') + + +class UserManagerMixin: + def users_to_notify(self): + """Finds all users who have been inactive for `MONTHS` and not yet notified.""" + last_login = timezone.now() - relativedelta(months=user_deletion_config.MONTHS) + return self.filter(last_login__lte=last_login, notified=False) diff --git a/user_deletion/notifications.py b/user_deletion/notifications.py new file mode 100644 index 0000000..5b018b8 --- /dev/null +++ b/user_deletion/notifications.py @@ -0,0 +1,25 @@ +from django.conf import settings +from django.core.mail import send_mass_mail +from django.template.loader import render_to_string +from django.utils.translation import ugettext_lazy as _ +from pigeon.notification import Notification + + +def send_emails(notification): + messages = [] + context = {'site': notification.site} + for user in notification.users: + message = render_to_string(notification.template_name, context) + messages.append([ + notification.subject, + message, + settings.DEFAULT_FROM_EMAIL, + [user.email], + ]) + send_mass_mail(messages) + + +class DeletionNotification(Notification): + handlers = (send_emails,) + template_name = 'user_deletion/email.txt' + subject = _('Re-activate your account') diff --git a/user_deletion/templates/user_deletion/email.txt b/user_deletion/templates/user_deletion/email.txt new file mode 100644 index 0000000..659a8b5 --- /dev/null +++ b/user_deletion/templates/user_deletion/email.txt @@ -0,0 +1,7 @@ +{% load i18n %} +{% blocktrans %} +We have noticed that your account has been inactive for over 12 months now. + +Please log in to your account in order to keep it alive, otherwise it will be +deleted within the next four weeks. +{% endblocktrans %}