From 496624a711f7e9884d2c118d861dc14832a82e17 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Wed, 15 Feb 2017 13:30:12 +0100 Subject: [PATCH] Added support for domain quota. see #1030 --- modoboa/admin/app_settings.py | 26 +++++++++++--- modoboa/admin/factories.py | 3 +- modoboa/admin/forms/account.py | 9 ++--- modoboa/admin/forms/domain.py | 29 ++++----------- .../migrations/0010_auto_20170215_1328.py | 30 ++++++++++++++++ modoboa/admin/models/domain.py | 27 ++++++++++++-- modoboa/admin/models/mailbox.py | 24 +++++++------ .../templates/admin/domain_general_form.html | 1 + .../templates/admin/import_domains_form.html | 2 +- modoboa/admin/tests/test_account.py | 31 +++++++++++++--- modoboa/admin/tests/test_domain.py | 36 ++++++++----------- .../migrations/0012_auto_20170215_1034.py | 32 +++++++++++++++++ modoboa/lib/form_utils.py | 2 -- 13 files changed, 179 insertions(+), 73 deletions(-) create mode 100644 modoboa/admin/migrations/0010_auto_20170215_1328.py create mode 100644 modoboa/core/migrations/0012_auto_20170215_1034.py diff --git a/modoboa/admin/app_settings.py b/modoboa/admin/app_settings.py index 15ee53de3..433a21618 100644 --- a/modoboa/admin/app_settings.py +++ b/modoboa/admin/app_settings.py @@ -71,6 +71,15 @@ class AdminParametersForm(param_forms.AdminParametersForm): ) ) + default_mailbox_quota = forms.IntegerField( + label=ugettext_lazy("Default mailbox quota"), + initial=0, + help_text=ugettext_lazy( + "Default mailbox quota (in MB) applied to freshly created domains " + "with no value specified. A value of 0 means no quota." + ) + ) + auto_account_removal = YesNoField( label=ugettext_lazy("Automatic account removal"), initial=False, @@ -96,7 +105,8 @@ class AdminParametersForm(param_forms.AdminParametersForm): def __init__(self, *args, **kwargs): super(AdminParametersForm, self).__init__(*args, **kwargs) self.field_widths = { - "default_domain_quota": 2 + "default_domain_quota": 2, + "default_mailbox_quota": 2 } hide_fields = False dpath = None @@ -127,11 +137,19 @@ class AdminParametersForm(param_forms.AdminParametersForm): def clean_default_domain_quota(self): """Ensure quota is a positive integer.""" - if self.cleaned_data['default_domain_quota'] < 0: + if self.cleaned_data["default_domain_quota"] < 0: raise forms.ValidationError( - ugettext_lazy('Must be a positive integer') + ugettext_lazy("Must be a positive integer") ) - return self.cleaned_data['default_domain_quota'] + return self.cleaned_data["default_domain_quota"] + + def clean_default_mailbox_quota(self): + """Ensure quota is a positive integer.""" + if self.cleaned_data["default_mailbox_quota"] < 0: + raise forms.ValidationError( + ugettext_lazy("Must be a positive integer") + ) + return self.cleaned_data["default_mailbox_quota"] def load_admin_settings(): diff --git a/modoboa/admin/factories.py b/modoboa/admin/factories.py index e1bfce0b7..ad7eb13a3 100644 --- a/modoboa/admin/factories.py +++ b/modoboa/admin/factories.py @@ -16,6 +16,7 @@ class DomainFactory(PermissionFactory): django_get_or_create = ("name", ) type = "domain" + quota = 0 default_mailbox_quota = 10 enabled = True @@ -64,7 +65,7 @@ def populate_database(): 2 domains, 1 domain admin, 2 users """ - dom = DomainFactory(name="test.com") + dom = DomainFactory(name="test.com", quota=50) admin = UserFactory( username="admin@test.com", groups=('DomainAdmins', ), password='{PLAIN}toto' diff --git a/modoboa/admin/forms/account.py b/modoboa/admin/forms/account.py index 03c91ecc0..e41484396 100644 --- a/modoboa/admin/forms/account.py +++ b/modoboa/admin/forms/account.py @@ -187,7 +187,6 @@ class AccountFormGeneral(forms.ModelForm): class AccountFormMail(forms.Form, DynamicForm): - """Form to handle mail part.""" email = lib_fields.UTF8EmailField( @@ -198,8 +197,7 @@ class AccountFormMail(forms.Form, DynamicForm): help_text=_("Quota in MB for this mailbox. Define a custom value or " "use domain's default one. Leave empty to define an " "unlimited value (not allowed for domain " - "administrators)."), - widget=forms.widgets.TextInput(attrs={"class": "form-control"}) + "administrators).") ) quota_act = forms.BooleanField(required=False) aliases = lib_fields.UTF8AndEmptyUserEmailField( @@ -280,8 +278,11 @@ class AccountFormMail(forms.Form, DynamicForm): Check if quota is >= 0 only when the domain value is not used. """ cleaned_data = super(AccountFormMail, self).clean() + if not cleaned_data["email"]: + return cleaned_data + use_default_domain_quota = cleaned_data["quota_act"] condition = ( - not cleaned_data["quota_act"] and + not use_default_domain_quota and cleaned_data["quota"] is not None and cleaned_data["quota"] < 0) if condition: diff --git a/modoboa/admin/forms/domain.py b/modoboa/admin/forms/domain.py index 6cb90f7b2..5205a96e5 100644 --- a/modoboa/admin/forms/domain.py +++ b/modoboa/admin/forms/domain.py @@ -33,14 +33,6 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): type = forms.ChoiceField( label=ugettext_lazy("Type"), ) - default_mailbox_quota = forms.IntegerField( - label=ugettext_lazy("Default mailbox quota"), - required=False, - help_text=ugettext_lazy( - "Default quota in MB applied to mailboxes. Leave empty to use the " - "default value." - ) - ) aliases = DomainNameField( label=ugettext_lazy("Alias(es)"), required=False, @@ -53,15 +45,18 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): class Meta: model = Domain fields = ( - "name", "type", "default_mailbox_quota", "aliases", "enabled", - "enable_dns_checks") + "name", "type", "quota", "default_mailbox_quota", "aliases", + "enabled", "enable_dns_checks") def __init__(self, *args, **kwargs): self.oldname = None if "instance" in kwargs: self.oldname = kwargs["instance"].name super(DomainFormGeneral, self).__init__(*args, **kwargs) - + params = dict(param_tools.get_global_parameters("admin")) + self.fields["quota"].initial = params["default_domain_quota"] + self.fields["default_mailbox_quota"].initial = ( + params["default_mailbox_quota"]) extra_domain_types = reduce( lambda a, b: a + b, [result[1] for result in signals.extra_domain_types.send( @@ -69,6 +64,7 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): ) self.fields["type"].choices = DOMAIN_TYPES + extra_domain_types self.field_widths = { + "quota": 3, "default_mailbox_quota": 3 } @@ -80,14 +76,6 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): name = "aliases_%d" % (pos + 1) self._create_field(forms.CharField, name, dalias.name, 3) - def clean_default_mailbox_quota(self): - """Check quota value.""" - if self.cleaned_data["default_mailbox_quota"] is None: - return param_tools.get_global_parameter("default_domain_quota") - if self.cleaned_data["default_mailbox_quota"] < 0: - raise forms.ValidationError(_("Must be a positive integer")) - return self.cleaned_data["default_mailbox_quota"] - def clean(self): """Custom fields validation. @@ -165,7 +153,6 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm): class DomainFormOptions(forms.Form): - """A form containing options for domain creation.""" create_dom_admin = YesNoField( @@ -272,7 +259,6 @@ class DomainFormOptions(forms.Form): class DomainForm(TabForms): - """Domain edition form.""" def __init__(self, request, *args, **kwargs): @@ -342,7 +328,6 @@ class DomainForm(TabForms): class DomainWizard(WizardForm): - """Domain creation wizard.""" def __init__(self, request): diff --git a/modoboa/admin/migrations/0010_auto_20170215_1328.py b/modoboa/admin/migrations/0010_auto_20170215_1328.py new file mode 100644 index 000000000..e71d408ca --- /dev/null +++ b/modoboa/admin/migrations/0010_auto_20170215_1328.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-15 12:28 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin', '0009_auto_20170215_0948'), + ] + + operations = [ + migrations.AddField( + model_name='domain', + name='quota', + field=models.PositiveIntegerField(default=0, help_text='Quota in MB shared between mailboxes. A value of 0 means no quota.'), + ), + migrations.AlterField( + model_name='domain', + name='default_mailbox_quota', + field=models.PositiveIntegerField(default=0, help_text='Default quota in MB applied to mailboxes. A value of 0 means no quota.', verbose_name='Default mailbox quota'), + ), + migrations.AlterField( + model_name='mailbox', + name='quota', + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/modoboa/admin/models/domain.py b/modoboa/admin/models/domain.py index feeb8b7a8..3032fab66 100644 --- a/modoboa/admin/models/domain.py +++ b/modoboa/admin/models/domain.py @@ -42,8 +42,21 @@ class Domain(AdminObject): name = models.CharField(ugettext_lazy('name'), max_length=100, unique=True, help_text=ugettext_lazy("The domain name")) - # quota = models.IntegerField() - default_mailbox_quota = models.IntegerField() + quota = models.PositiveIntegerField( + default=0, + help_text=ugettext_lazy( + "Quota in MB shared between mailboxes. A value of 0 means " + "no quota." + ) + ) + default_mailbox_quota = models.PositiveIntegerField( + verbose_name=ugettext_lazy("Default mailbox quota"), + default=0, + help_text=ugettext_lazy( + "Default quota in MB applied to mailboxes. A value of 0 means " + "no quota." + ) + ) enabled = models.BooleanField( ugettext_lazy('enabled'), help_text=ugettext_lazy("Check to activate this domain"), @@ -144,6 +157,16 @@ class Domain(AdminObject): else: return "success" + @cached_property + def quota_usage(self): + """Return current quota usage.""" + if not self.quota: + return 0 + if not self.mailbox_set.exists(): + return 0 + return self.mailbox_set.aggregate( + total=models.Sum("quota"))["total"] + def add_admin(self, account): """Add a new administrator to this domain. diff --git a/modoboa/admin/models/mailbox.py b/modoboa/admin/models/mailbox.py index 3d26d77d4..215c5a155 100644 --- a/modoboa/admin/models/mailbox.py +++ b/modoboa/admin/models/mailbox.py @@ -81,7 +81,7 @@ class Mailbox(AdminObject): help_text=ugettext_lazy( "Mailbox address (without the @domain.tld part)") ) - quota = models.PositiveIntegerField() + quota = models.PositiveIntegerField(default=0) use_domain_quota = models.BooleanField(default=False) domain = models.ForeignKey(Domain) user = models.OneToOneField(User) @@ -218,7 +218,7 @@ class Mailbox(AdminObject): MailboxOperation.objects.create(type="delete", argument=self.mail_home) def set_quota(self, value=None, override_rules=False): - """Set or update quota's value for this mailbox. + """Set or update quota value for this mailbox. A value equal to 0 means the mailbox won't have any quota. The following cases allow people to define such behaviour: @@ -228,21 +228,23 @@ class Mailbox(AdminObject): :param integer value: the quota's value :param bool override_rules: allow to override defined quota rules """ - default_mailbox_quota = self.domain.default_mailbox_quota + old_quota = self.quota if value is None: if self.use_domain_quota: - self.quota = default_mailbox_quota + self.quota = self.domain.default_mailbox_quota else: self.quota = 0 - elif int(value) > default_mailbox_quota and not override_rules: - raise lib_exceptions.BadRequest( - _("Quota is greater than the allowed domain's limit (%dM)") % - default_mailbox_quota - ) else: self.quota = value - if not self.quota and default_mailbox_quota and not override_rules: - raise lib_exceptions.BadRequest(_("A quota is required")) + if self.quota == 0: + if self.domain.quota and not override_rules: + raise lib_exceptions.BadRequest(_("A quota is required")) + elif self.domain.quota: + quota_usage = self.domain.quota_usage + if old_quota: + quota_usage -= old_quota + if quota_usage + self.quota > self.domain.quota: + raise lib_exceptions.BadRequest(_("Domain quota exceeded")) def get_quota(self): """Get quota limit. diff --git a/modoboa/admin/templates/admin/domain_general_form.html b/modoboa/admin/templates/admin/domain_general_form.html index 0364e74e1..4caca3a92 100644 --- a/modoboa/admin/templates/admin/domain_general_form.html +++ b/modoboa/admin/templates/admin/domain_general_form.html @@ -1,6 +1,7 @@ {% load i18n form_tags %} {% render_field form.name %} {% render_field form.type %} +{% render_field form.quota appended_text=_("MB") %} {% render_field form.default_mailbox_quota appended_text=_("MB") %} {% render_fields_group form "aliases" %} {% render_field form.enabled %} diff --git a/modoboa/admin/templates/admin/import_domains_form.html b/modoboa/admin/templates/admin/import_domains_form.html index 5a8666ef5..d1777468d 100644 --- a/modoboa/admin/templates/admin/import_domains_form.html +++ b/modoboa/admin/templates/admin/import_domains_form.html @@ -6,7 +6,7 @@ {% blocktrans trimmed %} Provide a CSV file where lines respect one of the following formats: diff --git a/modoboa/admin/tests/test_account.py b/modoboa/admin/tests/test_account.py index 334effba5..f8b9478a2 100644 --- a/modoboa/admin/tests/test_account.py +++ b/modoboa/admin/tests/test_account.py @@ -165,17 +165,34 @@ class AccountTestCase(ModoTestCase): self._set_quota("user@test.com", 0) def test_set_nul_quota_as_domainadmin(self): + """Check cases where a domain admin set unlimited quota.""" self.client.logout() self.assertTrue( self.client.login(username="admin@test.com", password="toto") ) + # Fails because domain has a quota self._set_quota("user@test.com", 0, 400) self.client.logout() self.assertTrue( self.client.login(username="admin@test2.com", password="toto") ) + # Ok because domain has no quota self._set_quota("user@test2.com", 0) + def test_domain_quota(self): + """Check domain quota.""" + dom = models.Domain.objects.get(name="test.com") + dom.quota = 100 + dom.save(update_fields=["quota"]) + # 2 x 10MB + self.assertEqual(dom.quota_usage, 20) + self._set_quota("user@test.com", 80) + del dom.quota_usage + # 10 + 80 < 100 => ok + self.assertEqual(dom.quota_usage, 90) + # 30 + 80 > 100 => failure + self._set_quota("admin@test.com", 30, 400) + def test_master_user(self): """Validate the master user mode.""" values = { @@ -250,11 +267,15 @@ class PermissionsTestCase(ModoTestCase): cls.reseller = core_factories.UserFactory(username="reseller") cls.reseller.role = "Resellers" cls.user = User.objects.get(username="user@test.com") - cls.values = dict( - username=cls.user.username, role="DomainAdmins", - is_active=cls.user.is_active, email="user@test.com", - quota_act=True - ) + + def setUp(self): + """Initiate test context.""" + super(PermissionsTestCase, self).setUp() + self.values = { + "username": self.user.username, "role": "DomainAdmins", + "is_active": self.user.is_active, "email": "user@test.com", + "quota_act": True + } def tearDown(self): self.client.logout() diff --git a/modoboa/admin/tests/test_domain.py b/modoboa/admin/tests/test_domain.py index 6e60b6dc9..859d985d6 100644 --- a/modoboa/admin/tests/test_domain.py +++ b/modoboa/admin/tests/test_domain.py @@ -25,12 +25,13 @@ class DomainTestCase(ModoTestCase): def test_create(self): """Test the creation of a domain.""" values = { - "name": "pouet.com", "default_mailbox_quota": 100, + "name": "pouet.com", "quota": 1000, "default_mailbox_quota": 100, "create_dom_admin": False, "type": "domain", "stepid": "step3" } self.ajax_post(reverse("admin:domain_add"), values) dom = Domain.objects.get(name="pouet.com") self.assertEqual(dom.name, "pouet.com") + self.assertEqual(dom.quota, 1000) self.assertEqual(dom.default_mailbox_quota, 100) self.assertEqual(dom.enabled, False) self.assertFalse(dom.admins) @@ -38,12 +39,13 @@ class DomainTestCase(ModoTestCase): def test_create_utf8(self): """Test the creation of a domain with non-ASCII characters.""" values = { - "name": "pouét.com", "default_mailbox_quota": 100, + "name": "pouét.com", "quota": 1000, "default_mailbox_quota": 100, "create_dom_admin": False, "type": "domain", "stepid": "step3" } self.ajax_post(reverse("admin:domain_add"), values) dom = Domain.objects.get(name=u"pouét.com") self.assertEqual(dom.name, u"pouét.com") + self.assertEqual(dom.quota, 1000) self.assertEqual(dom.default_mailbox_quota, 100) self.assertEqual(dom.enabled, False) self.assertFalse(dom.admins) @@ -51,7 +53,7 @@ class DomainTestCase(ModoTestCase): def test_create_with_template(self): """Test the creation of a domain with a template.""" values = { - "name": "pouet.com", "default_mailbox_quota": 100, + "name": "pouet.com", "quota": 1000, "default_mailbox_quota": 100, "create_dom_admin": True, "dom_admin_username": "toto", "create_aliases": True, "type": "domain", "stepid": 'step3' } @@ -67,7 +69,7 @@ class DomainTestCase(ModoTestCase): self.assertTrue(da.can_access(al)) values = { - "name": "pouet2.com", "default_mailbox_quota": 100, + "name": "pouet2.com", "quota": 1000, "default_mailbox_quota": 100, "create_dom_admin": True, "dom_admin_username": "postmaster", "create_aliases": True, "type": "domain", "stepid": "step3" } @@ -81,7 +83,7 @@ class DomainTestCase(ModoTestCase): def test_create_with_template_and_empty_quota(self): """Test the creation of a domain with a template and no quota""" values = { - "name": "pouet.com", "default_mailbox_quota": 0, + "name": "pouet.com", "quota": 0, "default_mailbox_quota": 0, "create_dom_admin": True, "dom_admin_username": "toto", "create_aliases": True, "type": "domain", "stepid": 'step3' } @@ -104,7 +106,7 @@ class DomainTestCase(ModoTestCase): password = "Toto1000" self.set_global_parameter("default_password", password, app="core") values = { - "name": "pouet.com", "default_mailbox_quota": 0, + "name": "pouet.com", "quota": 0, "default_mailbox_quota": 0, "create_dom_admin": True, "dom_admin_username": "toto", "create_aliases": True, "type": "domain", "stepid": 'step3' } @@ -125,20 +127,12 @@ class DomainTestCase(ModoTestCase): self.client.login(username="toto@pouet.com", password=password)) def test_create_using_default_quota(self): - self.set_global_parameter("default_domain_quota", 50) - values = { - "name": "pouet.com", "create_dom_admin": True, - "dom_admin_username": "toto", "create_aliases": True, - "type": "domain", "stepid": 'step3' - } - self.ajax_post( - reverse("admin:domain_add"), - values - ) - dom = Domain.objects.get(name="pouet.com") - self.assertEqual(dom.default_mailbox_quota, 50) - da = User.objects.get(username="toto@pouet.com") - self.assertEqual(da.mailbox.quota, 50) + """Check that default value is used for creation.""" + self.set_global_parameter("default_domain_quota", 500) + self.set_global_parameter("default_mailbox_quota", 50) + response = self.client.get(reverse("admin:domain_add")) + self.assertContains(response, "value=\"500\"") + self.assertContains(response, "value=\"50\"") def test_modify(self): """Test the modification of a domain @@ -146,7 +140,7 @@ class DomainTestCase(ModoTestCase): Rename 'test.com' domain to 'pouet.com' """ values = { - "name": "pouet.com", "default_mailbox_quota": 100, + "name": "pouet.com", "quota": 1000, "default_mailbox_quota": 100, "type": "domain", "enabled": True } dom = Domain.objects.get(name="test.com") diff --git a/modoboa/core/migrations/0012_auto_20170215_1034.py b/modoboa/core/migrations/0012_auto_20170215_1034.py new file mode 100644 index 000000000..51e0fd7d3 --- /dev/null +++ b/modoboa/core/migrations/0012_auto_20170215_1034.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-15 09:34 +from __future__ import unicode_literals + +from django.db import migrations + + +def rename_settings(apps, schema_editor): + """Rename settings.""" + LocalConfig = apps.get_model("core", "LocalConfig") + lc = LocalConfig.objects.first() + condition = ( + lc and + "admin" in lc._parameters and + "default_domain_quota" in lc._parameters["admin"] + ) + if condition: + lc._parameters["admin"]["default_mailbox_quota"] = ( + lc._parameters["admin"]["default_domain_quota"]) + del lc._parameters["admin"]["default_domain_quota"] + lc.save(update_fields=["_parameters"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_auto_20161104_2217'), + ] + + operations = [ + migrations.RunPython(rename_settings) + ] diff --git a/modoboa/lib/form_utils.py b/modoboa/lib/form_utils.py index d8914c4d4..f04c6f5fc 100644 --- a/modoboa/lib/form_utils.py +++ b/modoboa/lib/form_utils.py @@ -21,7 +21,6 @@ from modoboa.lib.web_utils import render_to_json_response class WizardStep(object): - """A wizard step.""" def __init__(self, uid, formclass, title, formtpl=None, new_args=None): @@ -66,7 +65,6 @@ class WizardStep(object): class WizardForm(object): - """Custom wizard.""" __metaclass__ = abc.ABCMeta