Merge pull request #1198 from modoboa/feature/inactive_accounts_cleanup

core: Added management command to clean inactive accounts.
This commit is contained in:
Antoine Nguyen
2017-08-04 16:06:27 +02:00
committed by GitHub
8 changed files with 205 additions and 19 deletions
+54
View File
@@ -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
<https://wiki.dovecot.org/PostLoginScripting>`_.
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 "$@"
+15 -7
View File
@@ -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)
+10
View File
@@ -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,
+17 -10
View File
@@ -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(
@@ -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()
+35
View File
@@ -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."""
+1
View File
@@ -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):
+2 -2
View File
@@ -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)