Extra sender addresses for account.

see #802
This commit is contained in:
Antoine Nguyen
2016-09-11 17:42:53 +02:00
parent 1e549dc545
commit 936200710b
14 changed files with 242 additions and 57 deletions
+76 -41
View File
@@ -22,7 +22,7 @@ from modoboa.lib.permissions import get_account_roles
from modoboa.lib.validators import validate_utf8_email
from modoboa.lib.web_utils import render_to_json_response
from ..models import Domain, Mailbox, Alias
from .. import models
class AccountFormGeneral(forms.ModelForm):
@@ -197,6 +197,16 @@ class AccountFormMail(forms.Form, DynamicForm):
"a 'catchall' alias (ex: *@domain.tld)."
)
)
senderaddress = lib_fields.UTF8AndEmptyUserEmailField(
label=ugettext_lazy("Sender addresses"),
required=False,
help_text=ugettext_lazy(
"Additional sender address(es) for this account. The user will be "
"allowed to send emails using this address, even if it "
"does not exist locally. Indicate one address per input. Press "
"ENTER to add a new input."
)
)
def __init__(self, *args, **kwargs):
self.mb = kwargs.pop("instance", None)
@@ -211,14 +221,17 @@ class AccountFormMail(forms.Form, DynamicForm):
self.extra_fields.append(fname)
if self.mb is not None:
self.fields["email"].required = True
cpt = 1
qset = self.mb.aliasrecipient_set.filter(alias__internal=False)
for ralias in qset:
name = "aliases_%d" % cpt
for cpt, ralias in enumerate(qset):
name = "aliases_{}".format(cpt + 1)
self._create_field(
lib_fields.UTF8AndEmptyUserEmailField, name,
ralias.alias.address)
cpt += 1
for cpt, saddress in enumerate(self.mb.senderaddress_set.all()):
name = "senderaddress_{}".format(cpt + 1)
self._create_field(
lib_fields.UTF8AndEmptyUserEmailField, name,
saddress.address)
self.fields["email"].initial = self.mb.full_address
self.fields["quota_act"].initial = self.mb.use_domain_quota
if not self.mb.use_domain_quota and self.mb.quota:
@@ -229,6 +242,9 @@ class AccountFormMail(forms.Form, DynamicForm):
if len(args) and isinstance(args[0], QueryDict):
self._load_from_qdict(
args[0], "aliases", lib_fields.UTF8AndEmptyUserEmailField)
self._load_from_qdict(
args[0], "senderaddress",
lib_fields.UTF8AndEmptyUserEmailField)
def clean_email(self):
"""Ensure lower case emails"""
@@ -237,8 +253,8 @@ class AccountFormMail(forms.Form, DynamicForm):
if not domname:
return email
try:
self.domain = Domain.objects.get(name=domname)
except Domain.DoesNotExist:
self.domain = models.Domain.objects.get(name=domname)
except models.Domain.DoesNotExist:
raise forms.ValidationError(_("Domain does not exist"))
if not self.mb:
try:
@@ -254,23 +270,27 @@ class AccountFormMail(forms.Form, DynamicForm):
Check if quota is >= 0 only when the domain value is not used.
"""
super(AccountFormMail, self).clean()
if not self.cleaned_data["quota_act"] \
and self.cleaned_data['quota'] is not None:
if self.cleaned_data["quota"] < 0:
self.add_error("quota", _("Must be a positive integer"))
cleaned_data = super(AccountFormMail, self).clean()
condition = (
not cleaned_data["quota_act"] and
cleaned_data["quota"] is not None and
cleaned_data["quota"] < 0)
if condition:
self.add_error("quota", _("Must be a positive integer"))
self.aliases = []
for name, value in self.cleaned_data.iteritems():
if not name.startswith("aliases"):
continue
self.sender_addresses = []
for name, value in cleaned_data.iteritems():
if value == "":
continue
local_part, domname = split_mailbox(value)
if not Domain.objects.filter(name=domname).exists():
self.add_error(name, _("Local domain does not exist"))
break
self.aliases.append(value.lower())
return self.cleaned_data
if name.startswith("aliases"):
local_part, domname = split_mailbox(value)
if not models.Domain.objects.filter(name=domname).exists():
self.add_error(name, _("Local domain does not exist"))
continue
self.aliases.append(value.lower())
elif name.startswith("senderaddress"):
self.sender_addresses.append(value.lower())
return cleaned_data
def create_mailbox(self, user, account):
"""Create a mailbox associated to :kw:`account`."""
@@ -278,7 +298,7 @@ class AccountFormMail(forms.Form, DynamicForm):
raise lib_exceptions.PermDeniedException
core_signals.can_create_object.send(
self.__class__, context=user, object_type="mailboxes")
self.mb = Mailbox(
self.mb = models.Mailbox(
address=self.locpart, domain=self.domain, user=account,
use_domain_quota=self.cleaned_data["quota_act"])
self.mb.set_quota(self.cleaned_data["quota"],
@@ -311,12 +331,27 @@ class AccountFormMail(forms.Form, DynamicForm):
alias__address=alias).exists():
continue
local_part, domname = split_mailbox(alias)
al = Alias(address=alias, enabled=account.is_active)
al.domain = Domain.objects.get(name=domname)
al = models.Alias(address=alias, enabled=account.is_active)
al.domain = models.Domain.objects.get(name=domname)
al.save()
al.set_recipients([self.mb.full_address])
al.post_create(user)
def _update_sender_addresses(self):
"""Update mailbox sender addresses."""
for saddress in self.mb.senderaddress_set.all():
if saddress.address not in self.sender_addresses:
saddress.delete()
else:
self.sender_addresses.remove(saddress.address)
if not len(self.sender_addresses):
return
to_create = []
for saddress in self.sender_addresses:
to_create.append(
models.SenderAddress(address=saddress, mailbox=self.mb))
models.SenderAddress.objects.bulk_create(to_create)
def save(self, user, account):
"""Save or update account mailbox."""
if self.cleaned_data["email"] == "":
@@ -339,6 +374,7 @@ class AccountFormMail(forms.Form, DynamicForm):
account.save()
self._update_aliases(user, account)
self._update_sender_addresses()
return self.mb
@@ -361,7 +397,8 @@ class AccountPermissionsForm(forms.Form, DynamicForm):
if not hasattr(self, "account") or self.account is None:
return
for pos, dom in enumerate(Domain.objects.get_for_admin(self.account)):
qset = models.Domain.objects.get_for_admin(self.account)
for pos, dom in enumerate(qset):
name = "domains_%d" % (pos + 1)
self._create_field(lib_fields.DomainNameField, name, dom.name)
if len(args) and isinstance(args[0], QueryDict):
@@ -370,7 +407,8 @@ class AccountPermissionsForm(forms.Form, DynamicForm):
def save(self):
current_domains = [
dom.name for dom in Domain.objects.get_for_admin(self.account)
dom.name for dom in
models.Domain.objects.get_for_admin(self.account)
]
for name, value in self.cleaned_data.items():
if not name.startswith("domains"):
@@ -378,34 +416,31 @@ class AccountPermissionsForm(forms.Form, DynamicForm):
if value in ["", None]:
continue
if value not in current_domains:
domain = Domain.objects.get(name=value)
domain = models.Domain.objects.get(name=value)
domain.add_admin(self.account)
for domain in Domain.objects.get_for_admin(self.account):
for domain in models.Domain.objects.get_for_admin(self.account):
if not filter(lambda name: self.cleaned_data[name] == domain.name,
self.cleaned_data.keys()):
domain.remove_admin(self.account)
class AccountForm(TabForms):
"""Account edition form."""
def __init__(self, request, *args, **kwargs):
self.user = request.user
self.forms = [
dict(id="general", title=_("General"),
formtpl="admin/account_general_form.html",
cls=AccountFormGeneral,
new_args=[self.user], mandatory=True),
dict(id="mail",
title=_("Mail"), formtpl="admin/mailform.html",
cls=AccountFormMail),
dict(
id="perms", title=_("Permissions"),
formtpl="admin/permsform.html",
cls=AccountPermissionsForm
)
{"id": "general", "title": _("General"),
"formtpl": "admin/account_general_form.html",
"cls": AccountFormGeneral,
"new_args": [self.user], "mandatory": True},
{"id": "mail",
"title": _("Mail"), "formtpl": "admin/mailform.html",
"cls": AccountFormMail},
{"id": "perms", "title": _("Permissions"),
"formtpl": "admin/permsform.html",
"cls": AccountPermissionsForm}
]
cbargs = [self.user]
if "instances" in kwargs:
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.5 on 2016-09-10 08:30
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('admin', '0005_auto_20160719_0903'),
]
operations = [
migrations.CreateModel(
name='SenderAddress',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('address', models.EmailField(max_length=254)),
('mailbox', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='admin.Mailbox')),
],
),
migrations.AlterUniqueTogether(
name='senderaddress',
unique_together=set([('address', 'mailbox')]),
),
]
+13 -3
View File
@@ -1,10 +1,20 @@
"""Admin models."""
from .base import AdminObject
from .domain import Domain, DNSBLResult
from .domain_alias import DomainAlias
from .mailbox import Mailbox, Quota, MailboxOperation
from .mailbox import Mailbox, Quota, MailboxOperation, SenderAddress
from .alias import Alias, AliasRecipient
__all__ = [
'Domain', 'DNSBLResult', 'DomainAlias', 'Mailbox', 'Quota', 'Alias',
'MailboxOperation', 'AdminObject', 'AliasRecipient'
"AdminObject",
"Alias",
"AliasRecipient",
"DNSBLResult",
"Domain",
"DomainAlias",
"Mailbox",
"MailboxOperation",
"Quota",
"SenderAddress",
]
+20
View File
@@ -340,6 +340,26 @@ class Mailbox(AdminObject):
reversion.register(Mailbox)
@python_2_unicode_compatible
class SenderAddress(models.Model):
"""Extra sender address for Mailbox."""
address = models.EmailField()
mailbox = models.ForeignKey(Mailbox)
class Meta:
app_label = "admin"
unique_together = [
("address", "mailbox"),
]
def __str__(self):
"""Return address."""
return smart_text(self.address)
reversion.register(SenderAddress)
class MailboxOperation(models.Model):
"""An operation on a mailbox."""
+28 -1
View File
@@ -110,6 +110,33 @@ class SenderLoginMailboxMap(object):
)
class SenderLoginMailboxExtraMap(object):
"""Map file to list per-mailbox extra addresses."""
filename = "sql-sender-login-mailboxes-extra.cf"
# FIXME: is it necessary to filter against user status?
mysql = (
"SELECT concat(mb.address, '@', dom.name) FROM admin_mailbox mb "
"INNER JOIN admin_senderaddress sad ON sad.mailbox_id=mb.id "
"INNER JOIN admin_domain dom ON dom.id=mb.domain_id "
"WHERE sad.address='%s'"
)
postgres = (
"SELECT mb.address || '@' || dom.name) FROM admin_mailbox mb "
"INNER JOIN admin_senderaddress sad ON sad.mailbox_id=mb.id "
"INNER JOIN admin_domain dom ON dom.id=mb.domain_id "
"WHERE sad.address='%s'"
)
sqlite = (
"SELECT mb.address || '@' || dom.name) FROM admin_mailbox mb "
"INNER JOIN admin_senderaddress sad ON sad.mailbox_id=mb.id "
"INNER JOIN admin_domain dom ON dom.id=mb.domain_id "
"WHERE sad.address='%s'"
)
class SenderLoginAliasMap(object):
"""Map file to list authorized sender addresses (from aliases)."""
@@ -139,5 +166,5 @@ class SenderLoginAliasMap(object):
registry.add_files([
DomainsMap, DomainsAliasesMap, AliasesMap, MaintainMap,
SenderLoginAliasMap, SenderLoginMailboxMap
SenderLoginAliasMap, SenderLoginMailboxMap, SenderLoginMailboxExtraMap
])
+9
View File
@@ -493,6 +493,15 @@ Identities.prototype = {
return true;
}
});
$("#original_senderaddress").dynamic_input({
input_added: function($row) {
$row.find("label").html("");
},
input_removed: function($input) {
$input.parents(".form-group").remove();
return true;
}
});
$("#id_email").autocompleter({
from_character: "@",
choices: $.proxy(this.get_domain_list, this)
@@ -26,7 +26,7 @@
<th>{% trans "Full name" %}</th><td>{{ object.fullname }}</td>
</tr>
<tr>
<th>{% trans "Role" %}</th><td>{{ object.role }}</td>
<th>{% trans "Role" %}</th><td>{{ object.get_role_display }}</td>
</tr>
<tr>
<th>{% trans "Created" %}</th><td>{{ object.date_joined|date:"SHORT_DATETIME_FORMAT" }}</td>
@@ -82,6 +82,18 @@
{% endfor %}
</table>
</div>
<div class="panel panel-info">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Additional sender addresses" %}</h3>
</div>
<table class="table">
{% for saddress in object.mailbox.senderaddress_set.all %}
<tr><td>{{ saddress }}</td></tr>
{% empty %}
<tr><td>{% trans "No sender address defined" %}</td></tr>
{% endfor %}
</table>
</div>
{% endif %}
{% for template in templates.right %}
{% include template %}
@@ -1,5 +1,7 @@
{% load form_tags %}
{% render_field form.email %}
{% include "common/generic_field.html" with field=form.quota help_display_mode="tooltip" label_width="col-sm-4" appended_text=_("MB") activator=True activator_value=form.quota_act.value deactivate_if_empty=False activator_label=_("Use domain default value") activator_size="col-sm-5" %}
{% render_extra_fields form %}
{% render_fields_group form "aliases" %}
{% render_fields_group form "senderaddress" %}
+23
View File
@@ -70,6 +70,29 @@ class AccountTestCase(ModoTestCase):
alias__internal=True).exists()
)
def test_sender_address(self):
"""Check if sender addresses are saved."""
account = User.objects.get(username="user@test.com")
values = {
"username": "user@test.com", "first_name": "Tester",
"last_name": "Toto", "role": "SimpleUsers",
"quota_act": True, "is_active": True, "email": "user@test.com",
"senderaddress": "test@titi.com", "senderaddress_1": "toto@go.com"
}
self.ajax_post(
reverse("admin:account_change", args=[account.pk]), values)
self.assertEqual(
models.SenderAddress.objects.filter(
mailbox__address="user").count(),
2)
del values["senderaddress_1"]
self.ajax_post(
reverse("admin:account_change", args=[account.pk]), values)
self.assertEqual(
models.SenderAddress.objects.filter(
mailbox__address="user").count(),
1)
def test_conflicts(self):
"""Check if unicity constraints are respected."""
values = {
+2 -1
View File
@@ -11,5 +11,6 @@ class MapFilesTestCase(MapFilesTestCaseMixin, TestCase):
MAP_FILES = [
"sql-domains.cf", "sql-domain-aliases.cf", "sql-aliases.cf",
"sql-maintain.cf"
"sql-maintain.cf", "sql-sender-login-mailboxes.cf",
"sql-sender-login-mailboxes-extra.cf", "sql-sender-login-aliases.cf"
]
+1 -1
View File
@@ -174,7 +174,7 @@ def newaccount(request):
@login_required
@permission_required("core.change_user")
@reversion.create_revision()
def editaccount(request, pk, tplname="common/tabforms.html"):
def editaccount(request, pk):
account = User.objects.get(pk=pk)
if not request.user.can_access(account):
raise PermDeniedException
+14
View File
@@ -2,6 +2,20 @@
"""Core application constants."""
from django.utils.translation import ugettext_lazy
SIMPLEUSERS_ROLE = ("SimpleUsers", ugettext_lazy("Simple user"))
DOMAINADMINS_ROLE = ("DomainAdmins", ugettext_lazy("Domain administrator"))
RESELLERS_ROLE = ("Resellers", ugettext_lazy("Reseller"))
SUPERADMINS_ROLE = ("SuperAdmins", ugettext_lazy("Super administrator"))
ROLES = (
SIMPLEUSERS_ROLE,
DOMAINADMINS_ROLE,
RESELLERS_ROLE,
SUPERADMINS_ROLE,
)
ADMIN_GROUPS = [
"SuperAdmins",
"Resellers",
+7
View File
@@ -357,6 +357,13 @@ class User(PermissionsMixin):
self.save()
self._role = role
def get_role_display(self):
"""Return the display name of this role."""
for role in constants.ROLES:
if role[0] == self.role:
return role[1]
return _("Unknown")
@cached_property
def is_admin(self):
"""Shortcut to check if user is administrator."""
+6 -9
View File
@@ -1,17 +1,13 @@
# coding: utf-8
"""Object level permissions."""
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import ugettext_lazy
from modoboa.core import constants as core_constants
from modoboa.core.models import ObjectAccess, User
from modoboa.lib import events
SIMPLEUSERS_ROLE = ("SimpleUsers", ugettext_lazy("Simple user"))
DOMAINADMINS_ROLE = ("DomainAdmins", ugettext_lazy("Domain administrator"))
RESELLERS_ROLE = ("Resellers", ugettext_lazy("Reseller"))
SUPERADMINS_ROLE = ("SuperAdmins", ugettext_lazy("Super administrator"))
def get_account_roles(user, account=None):
"""Return the list of available account roles.
@@ -23,15 +19,16 @@ def get_account_roles(user, account=None):
:param ``User`` account: account beeing modified (None on creation)
:return: list of strings
"""
result = [SIMPLEUSERS_ROLE]
result = [core_constants.SIMPLEUSERS_ROLE]
filters = events.raiseQueryEvent(
"UserCanSetRole", user, "DomainAdmins", account
)
if user.has_perm("admin.add_domain") and \
(not filters or True in filters):
result += [DOMAINADMINS_ROLE]
result += [core_constants.DOMAINADMINS_ROLE]
if user.is_superuser:
result += [RESELLERS_ROLE, SUPERADMINS_ROLE]
result += [
core_constants.RESELLERS_ROLE, core_constants.SUPERADMINS_ROLE]
result += events.raiseQueryEvent("GetExtraRoles", user, account)
return sorted(result, key=lambda role: role[1])