diff --git a/doc/manual_installation/dovecot.rst b/doc/manual_installation/dovecot.rst index 1c936950b..95057eccd 100644 --- a/doc/manual_installation/dovecot.rst +++ b/doc/manual_installation/dovecot.rst @@ -400,3 +400,57 @@ Finally, configure the ``sieve`` plugin by editing the Restart Dovecot. Now, you can go to the :ref:`postfix` section to finish the installation. + +Last-login tracking +=================== + +To update the ``last_login`` attribute of an account after a succesful +IMAP or POP3 login, you can configure a `post-login script +`_. + +Open :file:`conf.d/10-master.conf` add the following configuration +(``imap`` and ``pop3`` services are already defined, you just need to +update them):: + + service imap { + executable = imap postlogin + } + + service pop3 { + executable = pop3 postlogin + } + + service postlogin { + executable = script-login /usr/local/bin/postlogin.sh + user = modoboa + unix_listener postlogin { + } + } + +Then, you must create a script named +:file:`/usr/local/bin/postlogin.sh`. According to your database +engine, the content will differ. + +PostgreSQL +---------- + +:: + #!/bin/sh + + psql -c "UPDATE core_user SET last_login=now() WHERE username='$USER'" > /dev/null + + exec "$@" + +MySQL +----- + +:: + #!/bin/sh + + DBNAME=XXX + DBUSER=XXX + DBPASSWORD=XXX + + echo "UPDATE core_user SET last_login=now() WHERE username='$USER'" | mysql -u $DBUSER -p$DBPASSWORD $DBNAME + + exec "$@" diff --git a/modoboa/admin/handlers.py b/modoboa/admin/handlers.py index ff9d7c954..427990efa 100644 --- a/modoboa/admin/handlers.py +++ b/modoboa/admin/handlers.py @@ -114,13 +114,21 @@ def mailbox_deleted_handler(sender, **kwargs): alias.delete() models.Quota.objects.filter(username=mb.full_address).delete() request = lib_signals.get_request() - if not request.localconfig.parameters.get_value( - "handle_mailboxes", raise_exception=False): - return - keepdir = request.POST.get("keepdir", "false") == "true" - if keepdir: - return - mb.delete_dir() + if request: + if not request.localconfig.parameters.get_value( + "handle_mailboxes", raise_exception=False): + return + keepdir = request.POST.get("keepdir", "false") == "true" + if keepdir: + return + mb.delete_dir() + else: + # Management command context + localconfig = core_models.LocalConfig.objects.first() + if not localconfig.parameters.get_value( + "handle_mailboxes", raise_exception=False): + return + mb.delete_dir() @receiver(signals.post_delete, sender=models.Mailbox) 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..c146dc89f 100644 --- a/modoboa/core/handlers.py +++ b/modoboa/core/handlers.py @@ -70,11 +70,15 @@ def log_object_removal(sender, instance, **kwargs): except Version.DoesNotExist: return logger = logging.getLogger("modoboa.admin") - msg = _("%(object)s '%(name)s' %(action)s by user %(user)s") % { + msg = _("%(object)s '%(name)s' %(action)s by ") % { "object": smart_text(version.content_type).capitalize(), - "name": version.object_repr, "action": _("deleted"), - "user": get_request().user.username + "name": version.object_repr, "action": _("deleted") } + request = get_request() + if request: + msg += _("user {}").format(request.user.username) + else: + msg += _("management command") logger.critical(msg) @@ -92,14 +96,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/core/tests/test_views.py b/modoboa/core/tests/test_views.py index 177ca940d..e1919731a 100644 --- a/modoboa/core/tests/test_views.py +++ b/modoboa/core/tests/test_views.py @@ -156,6 +156,7 @@ class SettingsTestCase(ModoTestCase): "core-default_password": "Toto1000", "limits-deflt_user_quota_limit": "0", "core-hide_features_widget": "False", + "core-inactive_account_threshold": "30", } def test_get_settings(self): 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)