From c613ad457251017681f3b74c7ff4783e7794602f Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 28 Jul 2017 10:06:29 +0200 Subject: [PATCH] core: Added management command to clean inactive accounts. see #1196 --- modoboa/core/app_settings.py | 10 +++ modoboa/core/handlers.py | 17 +++-- .../commands/clean_inactive_accounts.py | 71 +++++++++++++++++++ modoboa/core/tests/test_core.py | 35 +++++++++ modoboa/lib/permissions.py | 4 +- 5 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 modoboa/core/management/commands/clean_inactive_accounts.py diff --git a/modoboa/core/app_settings.py b/modoboa/core/app_settings.py index 327ae3af3..e407ed4a6 100644 --- a/modoboa/core/app_settings.py +++ b/modoboa/core/app_settings.py @@ -271,6 +271,16 @@ class GeneralParametersForm(param_forms.AdminParametersForm): sep3 = SeparatorField(label=ugettext_lazy("Miscellaneous")) + inactive_account_threshold = forms.IntegerField( + label=_("Inactive account threshold"), + initial=30, + help_text=_( + "An account with a last login date greater than this threshold " + "(in days) will be considered as inactive" + ), + widget=forms.TextInput(attrs={"class": "form-control"}) + ) + top_notifications_check_interval = forms.IntegerField( label=_("Top notifications check interval"), initial=30, diff --git a/modoboa/core/handlers.py b/modoboa/core/handlers.py index a953cdc20..76d607ff4 100644 --- a/modoboa/core/handlers.py +++ b/modoboa/core/handlers.py @@ -92,14 +92,17 @@ def create_local_config(sender, **kwargs): @receiver(signals.pre_delete, sender=models.User) def update_permissions(sender, instance, **kwargs): """Permissions cleanup.""" - from_user = get_request().user - if from_user == instance: - raise exceptions.PermDeniedException( - _("You can't delete your own account") - ) + request = get_request() + # request migth be None (management command context) + if request: + from_user = request.user + if from_user == instance: + raise exceptions.PermDeniedException( + _("You can't delete your own account") + ) - if not from_user.can_access(instance): - raise exceptions.PermDeniedException + if not from_user.can_access(instance): + raise exceptions.PermDeniedException # We send an additional signal before permissions are removed core_signals.account_deleted.send( diff --git a/modoboa/core/management/commands/clean_inactive_accounts.py b/modoboa/core/management/commands/clean_inactive_accounts.py new file mode 100644 index 000000000..87f3dfab2 --- /dev/null +++ b/modoboa/core/management/commands/clean_inactive_accounts.py @@ -0,0 +1,71 @@ +"""Inactive accounts cleanup tool.""" + +from __future__ import print_function, unicode_literals + +from dateutil.relativedelta import relativedelta +from six.moves import input + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from modoboa.parameters import tools as param_tools + +from ... import models + + +class Command(BaseCommand): + """Management command to clean inactive accounts.""" + + help = "Inactive accounts cleanup" + + def add_arguments(self, parser): + """Add extra arguments to command line.""" + parser.add_argument( + "--delete", action="store_true", default=False, + help="Delete inactive accounts (default is to disable)") + parser.add_argument( + "--dry-run", action="store_true", default=False, + help="Only look for inactive accounts, no action") + parser.add_argument( + "--silent", action="store_true", default=False, + help="Enable silent mode (no question)") + parser.add_argument( + "--verbose", action="store_true", default=False, + help="Enable verbose mode") + + def _log_inactive_accounts(self, qset): + """Log inactive accounts found.""" + print( + "The following inactive accounts have been found:", + file=self.stdout) + for account in qset.values("username", "last_login"): + print( + "> {}\n (last login: {})".format( + account["username"], account["last_login"]), + file=self.stdout + ) + + def handle(self, *args, **options): + """Command entry point.""" + inactive_account_threshold = param_tools.get_global_parameter( + "inactive_account_threshold") + qset = models.User.objects.filter( + is_active=True, is_superuser=False, + last_login__lt=timezone.now() - + relativedelta(days=inactive_account_threshold)) + if not qset.exists(): + return + if options["verbose"]: + self._log_inactive_accounts(qset) + if options["dry_run"]: + return + action = "delete" if options["delete"] else "disable" + if not options["silent"]: + answer = input( + "Do you want to {} those accounts? (y/N) ".format(action)) + if not answer.lower().startswith("y"): + return + if action == "disable": + qset.update(is_active=False) + else: + qset.delete() diff --git a/modoboa/core/tests/test_core.py b/modoboa/core/tests/test_core.py index ce01055a4..6ca505c39 100644 --- a/modoboa/core/tests/test_core.py +++ b/modoboa/core/tests/test_core.py @@ -5,10 +5,12 @@ from __future__ import unicode_literals import httmock from dateutil.relativedelta import relativedelta +from six import StringIO from django.core import management from django.core.urlresolvers import reverse from django.test import TestCase +from django.utils import timezone from modoboa.lib.tests import ModoTestCase @@ -72,6 +74,39 @@ class ManagementCommandsTestCase(TestCase): management.call_command("cleanlogs") self.assertEqual(models.Log.objects.count(), 1) + def test_clean_inactive_accounts(self): + """Run clean_inactive_accounts command.""" + management.call_command("load_initial_data") + + # no inactive account, should exit normaly + management.call_command("clean_inactive_accounts") + + last_login = timezone.now() - relativedelta(days=45) + account = factories.UserFactory( + username="user1@domain.test", groups=("SimpleUsers", ), + last_login=last_login + ) + management.call_command("clean_inactive_accounts", "--dry-run") + account.refresh_from_db() + self.assertTrue(account.is_active) + + out = StringIO() + management.call_command( + "clean_inactive_accounts", "--verbose", "--dry-run", stdout=out) + self.assertIn("user1@domain.test", out.getvalue()) + + management.call_command("clean_inactive_accounts", "--silent") + account.refresh_from_db() + self.assertFalse(account.is_active) + + account.is_active = True + account.save(update_fields=["is_active"]) + + management.call_command( + "clean_inactive_accounts", "--silent", "--delete") + with self.assertRaises(models.User.DoesNotExist): + account.refresh_from_db() + class ProfileTestCase(ModoTestCase): """Profile related tests.""" diff --git a/modoboa/lib/permissions.py b/modoboa/lib/permissions.py index d27bcdf2b..72a14621e 100644 --- a/modoboa/lib/permissions.py +++ b/modoboa/lib/permissions.py @@ -118,9 +118,9 @@ def ungrant_access_to_object(obj, user=None): def ungrant_access_to_objects(objects): - """Cancel all accesses for a given objects list + """Cancel all accesses for a given object list. - :param objects: a list of objects inheriting from ``model.Model`` + :param objects: a list of objects inheriting from ``models.Model`` """ for obj in objects: ct = ContentType.objects.get_for_model(obj)