Generic transport editor. (#1309)

Generic transport editor.

see #1301

* Fixed unit test.

* Added unit tests.

* Fixed unit test.

* Added transport module to test list.

* Moved relay transport backend to appropriate app.

see #1301

* Added missing file.
This commit is contained in:
Antoine Nguyen
2017-11-27 10:42:27 +01:00
committed by GitHub
parent 1129c94316
commit 98eea782a0
65 changed files with 1621 additions and 611 deletions
+3
View File
@@ -12,3 +12,6 @@ DEV
modoboa.iml
.tox
modoboa_test
.coverage
build/
dist/
+2 -1
View File
@@ -4,6 +4,7 @@ cache: pip
python:
- "2.7"
- "3.4"
- "3.6"
addons:
apt:
packages:
@@ -43,7 +44,7 @@ before_script:
script:
- python ./tests.py
- cd test_project
- coverage run --source ../modoboa manage.py test modoboa.core modoboa.lib modoboa.admin modoboa.limits modoboa.relaydomains
- coverage run --source ../modoboa manage.py test modoboa.core modoboa.lib modoboa.admin modoboa.limits modoboa.transport modoboa.relaydomains
after_success:
- codecov
+12
View File
@@ -42,3 +42,15 @@ file_filter = modoboa/admin/locale/<lang>/LC_MESSAGES/djangojs.po
source_file = modoboa/admin/locale/en/LC_MESSAGES/djangojs.po
source_lang = en
type = PO
[modoboa.transport-djangopo]
file_filter = modoboa/transport/locale/<lang>/LC_MESSAGES/django.po
source_file = modoboa/transport/locale/en/LC_MESSAGES/django.po
source_lang = en
type = PO
[modoboa.transport-djangojspo]
file_filter = modoboa/transport/locale/<lang>/LC_MESSAGES/djangojs.po
source_file = modoboa/transport/locale/en/LC_MESSAGES/djangojs.po
source_lang = en
type = PO
+8
View File
@@ -2,6 +2,9 @@
from __future__ import unicode_literals
from django.utils.translation import ugettext_lazy as _
DNSBL_PROVIDERS = [
"aspews.ext.sorbs.net",
"b.barracudacentral.org",
@@ -91,3 +94,8 @@ RESERVED_TLD = [
"localhost",
"test"
]
DOMAIN_TYPES = [
("domain", _("Domain")),
("relaydomain", _("Relay domain")),
]
+8 -14
View File
@@ -20,6 +20,7 @@ from modoboa.lib.form_utils import (
from modoboa.lib.web_utils import render_to_json_response
from modoboa.parameters import tools as param_tools
from .. import constants
from .. import lib
from .. import signals
@@ -28,11 +29,6 @@ from ..models import (
)
DOMAIN_TYPES = [
("domain", _("Domain")),
]
class DomainFormGeneral(forms.ModelForm, DynamicForm):
"""A form to create/edit a domain."""
@@ -63,12 +59,7 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm):
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(
sender=self.__class__)]
)
self.fields["type"].choices = DOMAIN_TYPES + extra_domain_types
self.fields["type"].choices = constants.DOMAIN_TYPES
self.field_widths = {
"quota": 3,
"default_mailbox_quota": 3
@@ -382,10 +373,13 @@ class DomainForm(TabForms):
first_form = self.forms[0]["instance"]
options = {}
if isinstance(first_form, DomainFormGeneral):
options["domalias_post_create"] = True
first_form.save(self.request.user, **options)
domain = first_form.save(
self.request.user, domalias_post_create=True)
options.update({"domain": domain})
else:
first_form.save(self.request.user)
for f in self.forms[1:]:
f["instance"].save(self.request.user)
f["instance"].save(self.request.user, **options)
def done(self):
return render_to_json_response(_("Domain modified"))
+19
View File
@@ -5,11 +5,27 @@ from __future__ import unicode_literals
from django.db import models
from django.utils import timezone
from django.contrib.contenttypes.fields import GenericRelation
from modoboa.core import models as core_models
from modoboa.lib.permissions import (
grant_access_to_object, ungrant_access_to_object
)
class AdminObjectManager(models.Manager):
def get_for_admin(self, admin):
"""Return the objects belonging to this admin
The result is a ``QuerySet`` object, so this function can be used
to fill ``ModelChoiceField`` objects.
"""
if admin.is_superuser:
return self.get_queryset()
return self.get_queryset().filter(owners__user=admin)
class AdminObject(models.Model):
"""Abstract model to support dates.
@@ -20,8 +36,11 @@ class AdminObject(models.Model):
creation = models.DateTimeField(default=timezone.now)
last_modification = models.DateTimeField(auto_now=True)
owners = GenericRelation(core_models.ObjectAccess)
_objectname = None
objects = AdminObjectManager()
class Meta:
abstract = True
+6 -27
View File
@@ -3,10 +3,8 @@
from __future__ import unicode_literals
import datetime
from functools import reduce
from django.db import models
from django.db.models.manager import Manager
from django.utils import timezone
from django.utils.encoding import (
python_2_unicode_compatible, smart_text, force_text
@@ -14,31 +12,15 @@ from django.utils.encoding import (
from django.utils.functional import cached_property
from django.utils.translation import ugettext as _, ugettext_lazy
from django.contrib.contenttypes.fields import GenericRelation
from reversion import revisions as reversion
from modoboa.core import signals as core_signals
from modoboa.core.models import User, ObjectAccess
from modoboa.core.models import User
from modoboa.lib.exceptions import BadRequest, Conflict
from modoboa.parameters import tools as param_tools
from .base import AdminObject
from .. import constants
from .. import signals
class DomainManager(Manager):
def get_for_admin(self, admin):
"""Return the domains belonging to this admin
The result is a ``QuerySet`` object, so this function can be used
to fill ``ModelChoiceField`` objects.
"""
if admin.is_superuser:
return self.get_queryset()
return self.get_queryset().filter(owners__user=admin)
@python_2_unicode_compatible
@@ -67,15 +49,12 @@ class Domain(AdminObject):
help_text=ugettext_lazy("Check to activate this domain"),
default=True
)
owners = GenericRelation(ObjectAccess)
type = models.CharField(default="domain", max_length=20)
enable_dns_checks = models.BooleanField(
ugettext_lazy("Enable DNS checks"), default=True,
help_text=ugettext_lazy("Check to enable DNS checks for this domain")
)
objects = DomainManager()
class Meta:
permissions = (
("view_domain", "View domain"),
@@ -111,11 +90,11 @@ class Domain(AdminObject):
@property
def tags(self):
if self.type == "domain":
return [{"name": "domain", "label": _("Domain"), "type": "dom"}]
results = signals.get_domain_tags.send(
sender=self.__class__, domain=self)
return reduce(lambda a, b: a + b, [result[1] for result in results])
label = ""
for dt in constants.DOMAIN_TYPES:
if self.type == dt[0]:
label = dt[1]
return [{"name": self.type, "label": label, "type": "dom"}]
@property
def admins(self):
+2
View File
@@ -345,6 +345,7 @@ class Mailbox(AdminObject):
username=self.full_address)
super(Mailbox, self).save(*args, **kwargs)
reversion.register(Mailbox)
@@ -365,6 +366,7 @@ class SenderAddress(models.Model):
"""Return address."""
return smart_text(self.address)
reversion.register(SenderAddress)
+2 -2
View File
@@ -21,12 +21,12 @@ extra_domain_forms = django.dispatch.Signal(providing_args=["user", "domain"])
extra_domain_menu_entries = django.dispatch.Signal(providing_args=["user"])
extra_domain_qset_filters = django.dispatch.Signal(
providing_args=["domfilter", "extrafilters"])
extra_domain_types = django.dispatch.Signal()
# extra_domain_types = django.dispatch.Signal()
extra_domain_wizard_steps = django.dispatch.Signal()
get_account_form_instances = django.dispatch.Signal(
providing_args=["user", "account"])
get_domain_form_instances = django.dispatch.Signal(
providing_args=["user", "domain"])
get_domain_tags = django.dispatch.Signal(providing_args=["domain"])
# get_domain_tags = django.dispatch.Signal(providing_args=["domain"])
import_object = django.dispatch.Signal(providing_args=["objtype"])
use_external_recipients = django.dispatch.Signal(providing_args=["recipients"])
+1 -1
View File
@@ -57,7 +57,7 @@ class RepairTestCase(ModoTestCase):
"""Check that problem is fixed."""
count, detail = models.Alias.objects.filter(
address="user@test.com", internal=True).delete()
self.assertEqual(count, 2)
self.assertEqual(count, 3)
ret = management.call_command("modo", "repair", "--quiet")
assert ret is None
self.assertTrue(
+4 -5
View File
@@ -45,11 +45,10 @@ def index(request):
)
def _domains(request):
sort_order, sort_dir = get_sort_order(request.GET, "name")
extra_filters = reduce(
lambda a, b: a + b,
[result[1] for result in
signals.extra_domain_filters.send(sender="_domains")]
)
extra_filters = signals.extra_domain_filters.send(sender="_domains")
if extra_filters:
extra_filters = reduce(
lambda a, b: a + b, [result[1] for result in extra_filters])
filters = dict(
(flt, request.GET.get(flt, None))
for flt in ["domfilter", "searchquery"] + extra_filters
@@ -63,6 +63,7 @@ MODOBOA_APPS = (
'modoboa.core',
'modoboa.lib',
'modoboa.admin',
'modoboa.transport',
'modoboa.relaydomains',
'modoboa.limits',
'modoboa.parameters',
@@ -14,7 +14,6 @@ from django.core.management.base import BaseCommand
from modoboa.lib.cryptutils import random_key
from modoboa.lib.permissions import add_permissions_to_group
import modoboa.relaydomains.models as relay_models
from ... import constants
from ... import extensions
@@ -58,18 +57,15 @@ class Command(BaseCommand):
lc.parameters.set_value("secret_key", random_key())
lc.save()
for service_name in ["relay", "smtp"]:
relay_models.Service.objects.get_or_create(name=service_name)
groups = list(constants.PERMISSIONS.keys())
for groupname in groups:
group, created = Group.objects.get_or_create(name=groupname)
results = signals.extra_role_permissions.send(
sender=self.__class__, role=groupname)
permissions = (
constants.PERMISSIONS.get(groupname, []) +
reduce(lambda a, b: a + b, [result[1] for result in results])
)
permissions = constants.PERMISSIONS.get(groupname, [])
if results:
permissions += reduce(
lambda a, b: a + b, [result[1] for result in results])
if not permissions:
continue
add_permissions_to_group(group, permissions)
+1 -2
View File
@@ -120,7 +120,6 @@ class SettingsTestCase(ModoTestCase):
"core-ldap_bind_password": "",
"limits-deflt_domain_domain_aliases_limit": "0",
"core-secret_key": ":?j3]QPWo!.'_c4n",
"relaydomains-master_cf_path": "/etc/postfix/master.cf",
"limits-deflt_domain_domain_admins_limit": "0",
"limits-enable_admin_limits": "True",
"core-ldap_bind_dn": "",
@@ -166,7 +165,7 @@ class SettingsTestCase(ModoTestCase):
"""Test settings display."""
url = reverse("core:parameters")
response = self.ajax_get(url)
for app in ["core", "admin", "limits", "relaydomains"]:
for app in ["core", "admin", "limits"]:
self.assertIn('data-app="{}"'.format(app), response["content"])
def test_save_settings(self):
+1 -1
View File
@@ -92,7 +92,7 @@ class DomainLimitsForm(forms.Form):
self.add_error(lname, _("Invalid limit"))
return cleaned_data
def save(self, user):
def save(self, user, **kwargs):
"""Set limits."""
for name, ltpl in utils.get_domain_limit_templates():
fieldname = "{}_limit".format(name)
-24
View File
@@ -1,24 +0,0 @@
"""Relay domains settings definition."""
from __future__ import unicode_literals
from django import forms
from django.utils.translation import ugettext_lazy
from modoboa.lib.form_utils import SeparatorField
from modoboa.parameters import forms as param_forms
class AdminParametersForm(param_forms.AdminParametersForm):
"""Admin parameters definition."""
app = "relaydomains"
general_sep = SeparatorField(label=ugettext_lazy("General"))
master_cf_path = forms.CharField(
label=ugettext_lazy("Postfix's master.cf path"),
initial="/etc/postfix/master.cf",
help_text=ugettext_lazy('Path to the master.cf configuration file'),
widget=forms.TextInput(attrs={"class": "form-control"})
)
+1 -10
View File
@@ -3,15 +3,6 @@
from __future__ import unicode_literals
from django.apps import AppConfig
from django.utils.translation import ugettext as _
def load_relaydomains_settings():
"""Load application settings."""
from modoboa.parameters import tools as param_tools
from .app_settings import AdminParametersForm
param_tools.registry.add("global", AdminParametersForm, _("Relay domains"))
class RelayDomainsConfig(AppConfig):
@@ -22,5 +13,5 @@ class RelayDomainsConfig(AppConfig):
verbose_name = "Modoboa relay domains"
def ready(self):
load_relaydomains_settings()
from . import handlers
from . import transport
-14
View File
@@ -1,14 +0,0 @@
"""Relaydomains app constants."""
from __future__ import unicode_literals
PERMISSIONS = {
"Resellers": [
("relaydomains", "relaydomain", "add_relaydomain"),
("relaydomains", "relaydomain", "change_relaydomain"),
("relaydomains", "relaydomain", "delete_relaydomain"),
("relaydomains", "service", "add_service"),
("relaydomains", "service", "change_service"),
("relaydomains", "service", "delete_service")
]
}
+29 -38
View File
@@ -3,13 +3,11 @@
from __future__ import unicode_literals
from django import forms
from django.core import validators
from django.utils.translation import ugettext as _
from modoboa.lib.form_utils import WizardStep
from modoboa.lib import validators as lib_validators
from .models import RelayDomain
from modoboa.transport import backends as tr_backends
from modoboa.transport import forms as tr_forms
from modoboa.transport import models as tr_models
class RelayDomainWizardStep(WizardStep):
@@ -21,47 +19,40 @@ class RelayDomainWizardStep(WizardStep):
return wizard.steps[0].form.cleaned_data["type"] == "relaydomain"
class RelayDomainFormGeneral(forms.ModelForm):
"""RelayDomain form."""
class RelayDomainFormGeneral(tr_forms.BackendSettingsMixin, forms.ModelForm):
"""A form to display transport of type relay."""
class Meta(object):
model = RelayDomain
exclude = ["domain", "creation", "last_modification"]
widgets = {
"service": forms.Select(attrs={"class": "form-control"})
}
model = tr_models.Transport
exclude = [
"creation", "pattern", "service", "next_hop", "enabled",
"_settings"
]
def __init__(self, *args, **kwargs):
super(RelayDomainFormGeneral, self).__init__(*args, **kwargs)
self.field_widths = {
"service": 3
}
settings = tr_backends.manager.get_backend_settings("relay")
self.inject_backend_settings("relay", settings)
def clean_target_host(self):
"""Check that target host is valid."""
validator_list = [
lib_validators.validate_hostname,
validators.validate_ipv46_address
]
value = self.cleaned_data.get("target_host")
for validator in validator_list:
try:
validator(value)
except forms.ValidationError:
pass
else:
return value
raise forms.ValidationError(_("Invalid value"), code="invalid")
def clean(self):
"""Check values."""
cleaned_data = super(RelayDomainFormGeneral, self).clean()
if self.errors:
return cleaned_data
self.clean_backend_fields("relay")
return cleaned_data
def save(self, *args, **kwargs):
"""Custom save method."""
domain = kwargs.get("domain")
instance = super(RelayDomainFormGeneral, self).save(commit=False)
if instance.domain and instance.domain.type == "domain":
# Avoid new creation if domain type has been changed.
domain = kwargs.pop("domain", None)
if domain.type != "relaydomain":
# We don't want to recreate the transport we just deleted
# (post_save signal).
return None
if domain:
instance.domain = domain
if instance.domain.type == "relaydomain":
instance.save()
instance = super(RelayDomainFormGeneral, self).save(commit=False)
instance.pattern = domain.name
instance.service = "relay"
instance.enabled = domain.enabled
self.backend.serialize(instance)
instance.save()
return instance
+45 -82
View File
@@ -2,18 +2,16 @@
from __future__ import unicode_literals
from django.conf import settings
from django.db.models import signals
from django.dispatch import receiver
from django.template import Template, Context
from django.utils.translation import ugettext as _
from modoboa.admin import models as admin_models
from modoboa.admin import signals as admin_signals
from modoboa.core import signals as core_signals
from modoboa.lib.email_utils import split_mailbox
from modoboa.transport import models as tr_models
from . import constants
from . import forms
from . import lib
from . import models
@@ -21,38 +19,31 @@ from . import postfix_maps
@receiver(admin_signals.use_external_recipients)
def check_relaydomain_alias(sender, **kwargs):
def check_relaydomain_alias(sender, recipients, **kwargs):
"""Allow the creation of an alias on a relaydomain."""
recipient = kwargs.get("recipients")
if not recipient:
return
localpart, domain = split_mailbox(recipient)
if not (models.RelayDomain.objects.select_related().filter(
domain__name=domain).exists()):
localpart, domain = split_mailbox(recipients)
qset = admin_models.Domain.objects.filter(name=domain, type="relaydomain")
if not qset.exists():
return False
if (admin_models.Mailbox.objects.select_related("domain").filter(
domain__name=domain, address=localpart).exists()):
qset = admin_models.Mailbox.objects.select_related("domain").filter(
domain__name=domain, address=localpart)
if qset.exists():
return False
return True
@receiver(signals.post_save, sender=admin_models.Domain)
def clean_domain(sender, instance, **kwargs):
"""Remove or create RelayDomain record if needed."""
if kwargs.get("created"):
"""Remove or create Transport record if needed."""
if kwargs.get("created") or instance.type == "relaydomain":
return
if instance.type == "domain":
try:
instance.relaydomain.delete()
except models.RelayDomain.DoesNotExist:
pass
else:
# Make sure to create a RelayDomain instance since we can't do it
# at form level...
models.RelayDomain.objects.get_or_create(
domain=instance,
defaults={"service": models.Service.objects.first()}
)
tr_models.Transport.objects.filter(pattern=instance.name).delete()
@receiver(signals.pre_delete, sender=admin_models.Domain)
def delete_transport(sender, instance, **kwargs):
"""Delete Transport instance if any."""
tr_models.Transport.objects.filter(pattern=instance.name).delete()
@receiver(core_signals.register_postfix_maps)
@@ -60,41 +51,11 @@ def register_postfix_maps(sender, **kwargs):
"""Register postfix maps."""
return [
postfix_maps.RelayDomainsMap,
postfix_maps.RelayDomainsTransportMap,
postfix_maps.SplitedDomainsTransportMap,
postfix_maps.RelayRecipientVerification
]
@receiver(core_signals.extra_role_permissions)
def extra_role_permissions(sender, role, **kwargs):
"""Add permissions to the Resellers group."""
return constants.PERMISSIONS.get(role, [])
@receiver(core_signals.extra_static_content)
def static_content(sender, caller, st_type, user, **kwargs):
"""Add extra static content."""
if caller != "domains" or st_type != "js":
return ""
t = Template("""<script src="{{ STATIC_URL }}relaydomains/js/relay_domains.js" type="text/javascript"></script>
<script type="text/javascript">
var rdomain;
$(document).ready(function() {
rdomain = new RelayDomains({});
});
</script>
""")
return t.render(Context({"STATIC_URL": settings.STATIC_URL}))
@receiver(admin_signals.extra_domain_filters)
def extra_domain_filters(sender, **kwargs):
"""Return relaydomain filters."""
return ["srvfilter"]
@receiver(admin_signals.extra_domain_forms)
def extra_domain_form(sender, user, **kwargs):
"""Return relay settings for domain edition."""
@@ -114,28 +75,15 @@ def extra_domain_form(sender, user, **kwargs):
def fill_domain_instances(sender, user, domain, **kwargs):
"""Fill the relaydomain form with the right instance."""
condition = (
not user.has_perm("relaydomains.change_relaydomain") or
not user.has_perm("transport.change_transport") or
domain.type != "relaydomain"
)
if condition:
return {}
return {"relaydomain": domain.relaydomain}
@receiver(admin_signals.extra_domain_qset_filters)
def extra_domain_entries(sender, domfilter, extrafilters, **kwargs):
"""Return extra queryset filters."""
if domfilter is not None and domfilter and domfilter != "relaydomain":
return {}
if "srvfilter" in extrafilters and extrafilters["srvfilter"]:
return {"relaydomain__service__name": extrafilters["srvfilter"]}
return {}
@receiver(admin_signals.extra_domain_types)
def extra_domain_types(sender, **kwargs):
"""Declare the relay domain type."""
return [("relaydomain", _("Relay domain"))]
return {
"relaydomain": (
tr_models.Transport.objects.filter(pattern=domain.name).first())
}
@receiver(admin_signals.extra_domain_wizard_steps)
@@ -147,17 +95,32 @@ def extra_wizard_step(sender, **kwargs):
)]
@receiver(admin_signals.get_domain_tags)
def get_tags_for_domain(sender, domain, **kwargs):
"""Return relay domain custom tags."""
if domain.type != "relaydomain":
return []
return domain.relaydomain.tags
@receiver(admin_signals.import_object)
def get_import_func(sender, objtype, **kwargs):
"""Return function used to import objtype."""
if objtype == "relaydomain":
return lib.import_relaydomain
return None
@receiver(signals.post_save, sender=tr_models.Transport)
def update_recipient_access(sender, instance, **kwargs):
"""Create or delete recipient access rule."""
if instance.service != "relay":
return
if instance._settings.get("relay_verify_recipients", False):
models.RecipientAccess.objects.get_or_create(
pattern=instance.pattern,
defaults={"action": "reject_unverified_recipient"}
)
else:
models.RecipientAccess.objects.filter(
pattern=instance.pattern).delete()
@receiver(signals.post_delete, sender=tr_models.Transport)
def delete_recipient_access(sender, instance, **kwargs):
"""Delete recipient access rule."""
if instance.service != "relay":
return
models.RecipientAccess.objects.filter(pattern=instance.pattern).delete()
+23 -2
View File
@@ -2,9 +2,30 @@
from __future__ import unicode_literals
from .models import RelayDomain
from django.utils.translation import ugettext as _
from modoboa.admin import models as admin_models
from modoboa.lib.exceptions import BadRequest
from modoboa.transport import backends as tr_backends
from modoboa.transport import models as tr_models
def import_relaydomain(user, row, formopts):
"""Specific code for relay domains import"""
RelayDomain().from_csv(user, row)
if len(row) != 7:
raise BadRequest(_("Invalid line"))
domain = admin_models.Domain(
name=row[1].strip(), type="relaydomain", quota=0,
enabled=(row[5].strip().lower() in ["true", "1", "yes", "y"])
)
domain.save(creator=user)
settings = {
"relay_target_host": row[2].strip(),
"relay_target_port": row[3].strip(),
"relay_verify_recipients": (
row[6].strip().lower() in ["true", "1", "yes", "y"])
}
transport = tr_models.Transport(
pattern=domain.name, service="relay", _settings=settings)
tr_backends.manager.get_backend("relay").serialize(transport)
transport.save(creator=user)
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-11-24 14:06
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('relaydomains', '0006_auto_20170215_0948'),
]
operations = [
migrations.CreateModel(
name='RecipientAccess',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('pattern', models.CharField(max_length=254, unique=True)),
('action', models.CharField(max_length=40)),
],
),
]
@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-11-23 15:53
from __future__ import unicode_literals
from django.db import migrations
def move_relaydomain_to_transport(apps, schema_editor):
"""Transform relaydomains to transports."""
RelayDomain = apps.get_model("relaydomains", "RelayDomain")
RecipientAccess = apps.get_model("relaydomains", "RecipientAccess")
Transport = apps.get_model("transport", "Transport")
tr_to_create = []
ra_to_create = []
for rd in RelayDomain.objects.select_related("domain", "service"):
next_hop = "[{}]:{}".format(rd.target_host, rd.target_port)
tr = Transport(
pattern=rd.domain.name,
service="relay",
enabled=rd.domain.enabled,
next_hop=next_hop,
_settings={
"relay_target_host": rd.target_host,
"relay_target_port": rd.target_port,
"relay_verify_recipients": rd.verify_recipients
}
)
tr_to_create.append(tr)
if not rd.verify_recipients:
continue
ra_to_create.append(
RecipientAccess(
pattern=rd.domain.name, action="reject_unverified_recipient"))
Transport.objects.bulk_create(tr_to_create)
RecipientAccess.objects.bulk_create(ra_to_create)
def forward(apps, schema_editor):
"""Empty."""
pass
class Migration(migrations.Migration):
dependencies = [
('relaydomains', '0007_recipientaccess'),
('transport', '0001_initial'),
]
operations = [
migrations.RunPython(move_relaydomain_to_transport, forward)
]
@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-11-24 14:08
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('relaydomains', '0008_auto_20171123_1653'),
]
operations = [
migrations.RemoveField(
model_name='relaydomain',
name='domain',
),
migrations.RemoveField(
model_name='relaydomain',
name='service',
),
migrations.DeleteModel(
name='RelayDomain',
),
migrations.DeleteModel(
name='Service',
),
]
+4 -131
View File
@@ -3,137 +3,10 @@
from __future__ import unicode_literals
from django.db import models
from django.db.models.manager import Manager
from django.utils.translation import ugettext as _, ugettext_lazy
from reversion import revisions as reversion
from modoboa.admin import models as admin_models
from modoboa.lib.exceptions import BadRequest
from modoboa.parameters import tools as param_tools
class ServiceManager(Manager):
class RecipientAccess(models.Model):
"""An recipient level access table."""
def load_from_master_cf(self):
"""Load services from the master.cf file.
Parse the configuration file to update the service table. New
entries are saved and outdated ones (ie. present in the
database but not in the file) are removed.
"""
with open(param_tools.get_global_parameter("master_cf_path")) as fp:
content = fp.read()
services = []
for line in content.split('\n'):
if not line or line.startswith('#'):
continue
parts = line.strip().split()
if len(parts) != 8:
continue
if parts[7] != 'smtp':
continue
srv, created = self.get_or_create(name=parts[0])
services.append(parts[0])
to_delete = []
for service in self.all():
if service.name not in services:
to_delete.append(service.name)
Service.objects.filter(name__in=to_delete).delete()
class Service(models.Model):
"""Postfix service."""
name = models.CharField(
ugettext_lazy('name'), max_length=100, unique=True,
help_text=ugettext_lazy('The service name')
)
objects = ServiceManager()
class Meta(object):
db_table = "postfix_relay_domains_service"
def __str__(self):
return self.name
class RelayDomain(admin_models.AdminObject):
"""Relay domain.
A relay domain differs from a usual domaine because its final
destination is not reached yet. It must be accepted by the MTA but
it will then be transfered to another one.
"""
domain = models.OneToOneField("admin.Domain", null=True)
target_host = models.CharField(
ugettext_lazy("target host address"), max_length=255,
help_text=ugettext_lazy(
"Remote address (hostname or IP) of this domain")
)
target_port = models.IntegerField(
ugettext_lazy("target host port"),
help_text=ugettext_lazy(
"Remote port of this domain"),
default=25
)
service = models.ForeignKey(Service)
verify_recipients = models.BooleanField(
ugettext_lazy('verify recipients'),
help_text=ugettext_lazy('Check for valid recipients'),
default=False
)
class Meta(object):
ordering = ["domain__name"]
db_table = "postfix_relay_domains_relaydomain"
@property
def tags(self):
return [
{"name": "relaydomain", "label": _("Relay Domain"), "type": "dom"},
{"name": self.service.name, "label": "%s:" % self.service.name,
"type": "srv", "color": "info"}
]
def __str__(self):
return self.domain.name
def to_csv(self, csvwriter):
"""Export this relay domain to CSV.
:param csvwriter:
"""
csvwriter.writerow(
["relaydomain", self.domain.name, self.target_host,
self.target_port, self.service.name, self.domain.enabled,
self.verify_recipients]
)
for dalias in self.domain.domainalias_set.all():
dalias.to_csv(csvwriter)
def from_csv(self, user, row):
"""Import a relay domain from CSV.
:param user: user importing the relay domain
:param str row: relay domain definition
"""
if len(row) != 7:
raise BadRequest(_("Invalid line"))
self.domain = admin_models.Domain(
name=row[1].strip(), type="relaydomain", quota=0,
enabled=(row[5].strip().lower() in ["true", "1", "yes", "y"])
)
self.domain.save(creator=user)
self.target_host = row[2].strip()
self.target_port = row[3].strip()
self.service, created = Service.objects.get_or_create(
name=row[4].strip())
self.verify_recipients = (
row[6].strip().lower() in ["true", "1", "yes", "y"])
self.save(creator=user)
reversion.register(RelayDomain)
pattern = models.CharField(unique=True, max_length=254)
action = models.CharField(max_length=40)
+12 -58
View File
@@ -22,40 +22,6 @@ class RelayDomainsMap(object):
)
class RelayDomainsTransportMap(object):
"""A transport map for relay domains."""
filename = "sql-relaydomains-transport.cf"
mysql = (
"SELECT CONCAT(srv.name, ':[', rdom.target_host, ']:'"
", rdom.target_port) "
"FROM postfix_relay_domains_service AS srv "
"INNER JOIN postfix_relay_domains_relaydomain AS rdom "
"ON rdom.service_id=srv.id "
"INNER JOIN admin_domain AS dom ON rdom.domain_id=dom.id "
"WHERE dom.enabled=1 AND dom.name='%s'"
)
postgres = (
"SELECT srv.name || ':[' || rdom.target_host || ']:' || "
"rdom.target_port "
"FROM postfix_relay_domains_service AS srv "
"INNER JOIN postfix_relay_domains_relaydomain AS rdom "
"ON rdom.service_id=srv.id "
"INNER JOIN admin_domain AS dom ON rdom.domain_id=dom.id "
"WHERE dom.enabled AND dom.name='%s'"
)
sqlite = (
"SELECT srv.name || ':[' || rdom.target_host || ']:' || "
"rdom.target_host "
"FROM postfix_relay_domains_service AS srv "
"INNER JOIN postfix_relay_domains_relaydomain AS rdom "
"ON rdom.service_id=srv.id "
"INNER JOIN admin_domain AS dom ON rdom.domain_id=dom.id "
"WHERE dom.enabled=1 AND dom.name='%s'"
)
class SplitedDomainsTransportMap(object):
"""A transport map for splited domains.
@@ -66,32 +32,29 @@ class SplitedDomainsTransportMap(object):
filename = "sql-spliteddomains-transport.cf"
mysql = (
"SELECT 'lmtp:unix:private/dovecot-lmtp' "
"FROM postfix_relay_domains_relaydomain AS rdom "
"INNER JOIN admin_domain AS dom "
"ON rdom.domain_id=dom.id "
"FROM admin_domain AS dom "
"INNER JOIN admin_mailbox AS mbox ON dom.id=mbox.domain_id "
"INNER JOIN core_user AS u ON mbox.user_id=u.id "
"WHERE dom.enabled=1 AND dom.name='%d' AND u.is_active=1 "
"WHERE dom.type='relaydomain' AND dom.enabled=1 "
"AND dom.name='%d' AND u.is_active=1 "
"AND mbox.address='%u'"
)
postgres = (
"SELECT 'lmtp:unix:private/dovecot-lmtp' "
"FROM postfix_relay_domains_relaydomain AS rdom "
"INNER JOIN admin_domain AS dom "
"ON rdom.domain_id=dom.id "
"FROM admin_domain AS dom "
"INNER JOIN admin_mailbox AS mbox ON dom.id=mbox.domain_id "
"INNER JOIN core_user AS u ON mbox.user_id=u.id "
"WHERE dom.enabled AND dom.name='%d' AND u.is_active "
"WHERE dom.type='relaydomain' AND dom.enabled "
"AND dom.name='%d' AND u.is_active "
"AND mbox.address='%u'"
)
sqlite = (
"SELECT 'lmtp:unix:private/dovecot-lmtp' "
"FROM postfix_relay_domains_relaydomain AS rdom "
"INNER JOIN admin_domain AS dom "
"ON rdom.domain_id=dom.id "
"FROM admin_domain AS dom "
"INNER JOIN admin_mailbox AS mbox ON dom.id=mbox.domain_id "
"INNER JOIN core_user AS u ON mbox.user_id=u.id "
"WHERE dom.enabled=1 AND dom.name='%d' AND u.is_active=1 "
"WHERE dom.type='relaydomain' AND dom.enabled=1 "
"AND dom.name='%d' AND u.is_active=1 "
"AND mbox.address='%u'"
)
@@ -102,20 +65,11 @@ class RelayRecipientVerification(object):
filename = "sql-relay-recipient-verification.cf"
mysql = (
"SELECT 'reject_unverified_recipient' "
"FROM postfix_relay_domains_relaydomain AS rdom "
"INNER JOIN admin_domain AS dom ON rdom.domain_id=dom.id "
"WHERE rdom.verify_recipients=1 AND dom.name='%d'"
"SELECT action FROM relaydomains_recipientaccess WHERE pattern='%d'"
)
postgres = (
"SELECT 'reject_unverified_recipient' "
"FROM postfix_relay_domains_relaydomain AS rdom "
"INNER JOIN admin_domain AS dom ON rdom.domain_id=dom.id "
"WHERE rdom.verify_recipients AND dom.name='%d'"
"SELECT action FROM relaydomains_recipientaccess WHERE pattern='%d'"
)
sqlite = (
"SELECT 'reject_unverified_recipient' "
"FROM postfix_relay_domains_relaydomain AS rdom "
"INNER JOIN admin_domain AS dom ON rdom.domain_id=dom.id "
"WHERE rdom.verify_recipients=1 AND dom.name='%d'"
"SELECT action FROM relaydomains_recipientaccess WHERE pattern='%d'"
)
@@ -1,70 +0,0 @@
/**
* @module relay_domains
* @desc Utility class to manage relay domains.
*/
var RelayDomains = function(options) {
this.initialize(options);
};
RelayDomains.prototype = {
constructor: RelayDomains,
defaults: {
},
initialize: function(options) {
this.options = $.extend({}, this.defaults, options);
admin.register_tag_handler("srv", this.srv_tag_handler);
$(document).bind('domform_init', $.proxy(this.domain_form_init_cb, this));
},
domain_form_init_cb: function() {
$('#btnscan').click(this.launch_scan);
},
launch_scan: function(e) {
var $link = get_target(e);
e.preventDefault();
$.ajax({
type: "POST",
url: $link.attr("href"),
global: false
}).done(function(data) {
var $widget = $('#id_service');
$widget.empty();
$.each(data, function(label, id) {
$widget.append($('<option />', {
value: id,
html: label
}));
});
$('.modal-body').prepend(
build_success_alert(gettext('Services updated'))
);
}).fail(function(jqxhr) {
var result = $.parseJSON(jqxhr.responseText);
$('.modal-body').prepend(
build_error_alert(result[0])
);
});
},
srv_tag_handler: function(tag, $link) {
if (this.navobj.getparam(tag + "filter") === undefined && $link.hasClass(tag)) {
var text = $link.attr("name");
this.navobj
.setparam("domfilter", "relaydomain")
.setparam(tag + "filter", text)
.update();
if ($("a[name=dom]").length === 0) {
$("#taglist").append(this.make_tag("relaydomain", "dom"));
}
$("#taglist").append(this.make_tag(text, tag));
return true;
}
return false;
}
};
@@ -1,6 +1,5 @@
{% load form_tags %}
{% render_field form.target_host %}
{% render_field form.target_port %}
{% include "relaydomains/service_field.html" with field=form.service label_width="col-sm-4" %}
{% render_field form.verify_recipients %}
{% render_field form.relay_target_host %}
{% render_field form.relay_target_port %}
{% render_field form.relay_verify_recipients %}
+89 -65
View File
@@ -12,6 +12,8 @@ from modoboa.core.factories import UserFactory
from modoboa.lib.tests import ModoTestCase
from modoboa.lib.test_utils import MapFilesTestCaseMixin
from modoboa.limits import utils as limits_utils
from modoboa.transport import factories as tr_factories
from modoboa.transport import models as tr_models
from . import models
@@ -19,12 +21,16 @@ from . import models
class Operations(object):
def _create_relay_domain(self, name, status=200, **kwargs):
srv, created = models.Service.objects.get_or_create(name='dummy')
values = {
"name": name, "create_dom_admin": False, "type": "relaydomain",
"target_host": "external.host.tld", "target_port": 25,
"service": srv.id, "enabled": True, "stepid": "step3",
"quota": 0, "default_mailbox_quota": 0
"name": name,
"create_dom_admin": False,
"type": "relaydomain",
"relay_target_host": "external.host.tld",
"relay_target_port": 25,
"enabled": True,
"stepid": "step3",
"quota": 0,
"default_mailbox_quota": 0
}
values.update(kwargs)
return self.ajax_post(
@@ -32,16 +38,17 @@ class Operations(object):
values, status
)
def _relay_domain_alias_operation(self, optype, rdomain, name, status=200):
rdom = models.RelayDomain.objects.get(domain__name=rdomain)
def _relay_domain_alias_operation(self, optype, domain, name, status=200):
transport = tr_models.Transport.objects.get(pattern=domain.name)
values = {
"name": rdom.domain.name, "target_host": rdom.target_host,
"target_port": rdom.target_port,
"type": "relaydomain", "service": rdom.service.id,
"quota": rdom.domain.quota,
"default_mailbox_quota": rdom.domain.default_mailbox_quota
"name": domain.name,
"relay_target_host": transport._settings["relay_target_host"],
"relay_target_port": transport._settings["relay_target_port"],
"type": "relaydomain",
"quota": domain.quota,
"default_mailbox_quota": domain.default_mailbox_quota
}
aliases = [alias.name for alias in rdom.domain.domainalias_set.all()]
aliases = [alias.name for alias in domain.domainalias_set.all()]
if optype == 'add':
aliases.append(name)
else:
@@ -51,7 +58,7 @@ class Operations(object):
values[fname] = alias
return self.ajax_post(
reverse("admin:domain_change",
args=[rdom.domain.id]),
args=[domain.id]),
values, status
)
@@ -68,15 +75,19 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
"""Create test data."""
super(RelayDomainsTestCase, cls).setUpTestData()
admin_factories.populate_database()
dom = admin_factories.DomainFactory(
cls.dom = admin_factories.DomainFactory(
name="relaydomain.tld", type="relaydomain")
dom.relaydomain.target_host = "external.host.tld"
dom.relaydomain.save()
cls.rdom = dom.relaydomain
cls.transport = tr_factories.TransportFactory(
pattern=cls.dom.name, _settings={
"relay_target_host": "external.host.tld",
"relay_target_port": "25",
"relay_verify_recipients": False
}
)
admin_factories.DomainAliasFactory(
name="relaydomainalias.tld", target=cls.rdom.domain)
name="relaydomainalias.tld", target=cls.dom)
admin_factories.MailboxFactory(
domain=cls.rdom.domain, address="local",
domain=cls.dom, address="local",
user__username="local@relaydomain.tld",
user__groups=("SimpleUsers", )
)
@@ -88,7 +99,7 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
self.assertEqual(response.status_code, 200)
content = response.json()
self.assertIn("relaydomain.tld", content["rows"])
self.assertIn("Relay Domain", content["rows"])
self.assertIn("Relay domain", content["rows"])
def test_create_relaydomain(self):
"""Test the creation of a relay domain.
@@ -99,11 +110,11 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
FIXME: add a check for domain alias.
"""
self._create_relay_domain('relaydomain1.tld')
rdom = models.RelayDomain.objects.get(domain__name='relaydomain1.tld')
self.assertEqual(rdom.target_host, 'external.host.tld')
self.assertEqual(rdom.service.name, 'dummy')
self.assertEqual(rdom.domain.enabled, True)
self.assertEqual(rdom.verify_recipients, False)
transport = tr_models.Transport.objects.get(pattern='relaydomain1.tld')
self.assertEqual(
transport._settings["relay_target_host"], 'external.host.tld')
self.assertEqual(
transport._settings["relay_verify_recipients"], False)
resp = self._create_relay_domain('test.com', 400)
self.assertEqual(resp['form_errors']['name'][0],
@@ -123,17 +134,17 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
FIXME: add a check for domain alias.
"""
self._relay_domain_alias_operation(
'add', self.rdom.domain.name, 'relaydomainalias1.tld'
'add', self.dom, 'relaydomainalias1.tld'
)
resp = self._relay_domain_alias_operation(
'add', self.rdom.domain.name, 'test.com', 400
'add', self.dom, 'test.com', 400
)
self.assertEqual(
resp['form_errors']['aliases_2'][0],
'A domain with this name already exists'
)
resp = self._relay_domain_alias_operation(
'add', self.rdom.domain.name, self.rdom.domain.name, 400
'add', self.dom, self.dom.name, 400
)
self.assertEqual(
resp['form_errors']['aliases_2'][0],
@@ -146,36 +157,49 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
Rename 'relaydomain.tld' domain to 'relaydomain.org'
"""
values = {
"name": "relaydomain.org", "target_host": self.rdom.target_host,
"target_port": 4040, "type": "relaydomain", "enabled": True,
"service": self.rdom.service.id,
"name": "relaydomain.org",
"relay_target_host": self.transport._settings["relay_target_host"],
"relay_target_port": 4040,
"relay_verify_recipients": True,
"type": "relaydomain",
"enabled": True,
"quota": 0, "default_mailbox_quota": 0
}
self.ajax_post(
reverse("admin:domain_change", args=[self.rdom.domain.id]),
reverse("admin:domain_change", args=[self.dom.id]),
values
)
self.rdom.refresh_from_db()
self.assertEqual(self.rdom.target_port, 4040)
self.transport.refresh_from_db()
self.assertEqual(
self.transport._settings["relay_target_port"], 4040)
self.assertTrue(
models.RecipientAccess.objects.filter(
pattern=values["name"]).exists())
values["relay_verify_recipients"] = False
self.ajax_post(
reverse("admin:domain_change", args=[self.dom.id]),
values
)
self.assertFalse(
models.RecipientAccess.objects.filter(
pattern=values["name"]).exists())
def test_relaydomain_domain_switch(self):
"""Check domain <-> relaydomain transitions."""
domain_pk = self.rdom.domain.pk
domain_pk = self.dom.pk
values = {
"name": "relaydomain.tld",
"type": "domain",
"quota": 0,
"default_mailbox_quota": 0,
"enabled": True,
"target_host": self.rdom.target_host,
"target_port": self.rdom.target_port,
"service": self.rdom.service.pk
"relay_target_host": self.transport._settings["relay_target_host"],
"relay_target_port": self.transport._settings["relay_target_port"]
}
self.ajax_post(
reverse("admin:domain_change", args=[domain_pk]), values)
self.assertFalse(
models.RelayDomain.objects.filter(
domain__pk=domain_pk).exists())
with self.assertRaises(tr_models.Transport.DoesNotExist):
self.transport.refresh_from_db()
self.assertEqual(
admin_models.Domain.objects.get(name="relaydomain.tld").type,
"domain")
@@ -191,9 +215,6 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
self.assertEqual(
admin_models.Domain.objects.get(name="relaydomain.tld").type,
"relaydomain")
self.assertTrue(
models.RelayDomain.objects.filter(
domain__name="relaydomain.tld").exists())
def test_edit_relaydomainalias(self):
"""Test the modification of a relay domain alias.
@@ -201,15 +222,15 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
Rename 'relaydomainalias.tld' domain to 'relaydomainalias.net'
"""
values = {
"name": "relaydomain.org", "target_host": self.rdom.target_host,
"target_port": self.rdom.target_port,
"type": "relaydomain", "service": self.rdom.service.id,
"name": "relaydomain.org",
"relay_target_host": self.transport._settings["relay_target_host"],
"relay_target_port": self.transport._settings["relay_target_port"],
"type": "relaydomain",
"aliases": "relaydomainalias.net",
"quota": 0, "default_mailbox_quota": 0
}
self.ajax_post(
reverse('admin:domain_change',
args=[self.rdom.domain.id]),
reverse('admin:domain_change', args=[self.dom.id]),
values
)
admin_models.DomainAlias.objects.get(name='relaydomainalias.net')
@@ -219,20 +240,23 @@ class RelayDomainsTestCase(ModoTestCase, Operations):
def test_delete_relaydomain(self):
"""Test the removal of a relay domain."""
self.ajax_post(
reverse("admin:domain_delete",
args=[self.rdom.domain.id]),
reverse("admin:domain_delete", args=[self.dom.id]),
{}
)
with self.assertRaises(models.RelayDomain.DoesNotExist):
models.RelayDomain.objects.get(domain__name='relaydomain.tld')
with self.assertRaises(tr_models.Transport.DoesNotExist):
self.transport.refresh_from_db()
def test_delete_relaydomainalias(self):
"""Test the removal of a relay domain alias."""
self._relay_domain_alias_operation(
'del', self.rdom.domain.name, 'relaydomainalias.tld'
def test_delete_recipientaccess(self):
"""Test the removal of a recipient access."""
self.transport._settings["relay_verify_recipients"] = True
self.transport.save()
self.ajax_post(
reverse("admin:domain_delete", args=[self.dom.id]),
{}
)
with self.assertRaises(models.RelayDomain.DoesNotExist):
models.RelayDomain.objects.get(domain__name='relaydomainalias.tld')
self.assertFalse(
models.RecipientAccess.objects.filter(
pattern=self.transport.pattern).exists())
def test_alias_on_relaydomain(self):
"""Create an alias on a relay domain."""
@@ -309,20 +333,21 @@ class LimitsTestCase(ModoTestCase, Operations):
def test_relay_domain_aliases_limit(self):
self._create_relay_domain(
'relaydomain1.tld', quota=1, default_mailbox_quota=1)
domain = admin_models.Domain.objects.get(name="relaydomain1.tld")
self._relay_domain_alias_operation(
'add', 'relaydomain1.tld', 'relay-domain-alias1.tld'
'add', domain, 'relay-domain-alias1.tld'
)
self._check_limit('domain_aliases', 1, 2)
self._relay_domain_alias_operation(
'add', 'relaydomain1.tld', 'relay-domain-alias2.tld'
'add', domain, 'relay-domain-alias2.tld'
)
self._check_limit('domain_aliases', 2, 2)
self._relay_domain_alias_operation(
'add', 'relaydomain1.tld', 'relay-domain-alias3.tld', 403
'add', domain, 'relay-domain-alias3.tld', 403
)
self._check_limit('domain_aliases', 2, 2)
self._relay_domain_alias_operation(
'delete', 'relaydomain1.tld', 'relay-domain-alias2.tld'
'delete', domain, 'relay-domain-alias2.tld'
)
self._check_limit('domain_aliases', 1, 2)
@@ -333,6 +358,5 @@ class MapFilesTestCase(MapFilesTestCaseMixin, TestCase):
MAP_FILES = [
"sql-relaydomains.cf",
"sql-relaydomains-transport.cf",
"sql-relay-recipient-verification.cf"
]
+29
View File
@@ -0,0 +1,29 @@
"""Transport backend definition."""
from django.utils.translation import ugettext_lazy as _
from modoboa.transport import backends
class RelayTransportBackend(backends.TransportBackend):
"""Relay backend class."""
name = "relay"
settings = (
{"name": "target_host", "label": _("target host address"),
"validator": "host_address"},
{"name": "target_port", "label": _("target host port"),
"type": "int", "default": 25},
{"name": "verify_recipients", "label": _("verify recipients"),
"type": "boolean", "required": False, "default": False},
)
def serialize(self, transport):
"""Make sure next_hop is set."""
transport.next_hop = "[{}]:{}".format(
transport._settings["relay_target_host"],
transport._settings["relay_target_port"])
backends.manager.register_backend(RelayTransportBackend)
-13
View File
@@ -1,13 +0,0 @@
"""Relay domains urls."""
from __future__ import unicode_literals
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^scan_services/$', views.scan_for_services,
name="service_scan"),
]
-26
View File
@@ -1,26 +0,0 @@
"""Custom views."""
from __future__ import unicode_literals
from django.contrib.auth.decorators import (
login_required, permission_required
)
from django.views.decorators.http import require_http_methods
from modoboa.lib.web_utils import render_to_json_response
from .models import Service
@login_required
@permission_required("relaydomains.add_service")
@require_http_methods(["POST"])
def scan_for_services(request):
try:
Service.objects.load_from_master_cf()
except IOError as e:
return render_to_json_response([str(e)], status=500)
return render_to_json_response(
dict((srv.name, srv.id) for srv in Service.objects.all())
)
+9
View File
@@ -255,6 +255,15 @@ html body div.navbar div.container-fluid div#navigate-navbar-collapse.collapse u
float:none;
}
/* Tables */
table th#actions {
text-align: right;
}
table td[name="actions"] {
text-align: right;
}
/* --------- specific pages --------- */
/* identities tables */
+2 -2
View File
@@ -188,8 +188,8 @@ function simple_ajax_form_post(e, options) {
histomanager.update(true);
}
}
if (data) {
$("body").notify('success', data, 2000);
if (data && data.message) {
$("body").notify('success', data.message, 2000);
}
}).fail(function(jqxhr) {
var data = $.parseJSON(jqxhr.responseText);
+10 -7
View File
@@ -3,11 +3,14 @@
{% load core_tags %}
{% block container_content %}
<div id="leftcol" class="sidebar collapse navbar-collapse">
{% block leftcol %}{% endblock %}
</div>
<div class="main">
{% if messages %}{% display_messages messages %}{% endif %}
{% block apparea %}{% endblock %}
</div>
<div id="leftcol" class="sidebar collapse navbar-collapse">
{% block leftcol %}{% endblock %}
</div>
<div class="main">
{% block apparea %}{% endblock %}
</div>
{% endblock %}
{% block extra_js %}
{% if messages %}{% display_messages messages %}{% endif %}
{% endblock %}
+1
View File
@@ -0,0 +1 @@
default_app_config = "modoboa.transport.apps.TransportConfig"
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+11
View File
@@ -0,0 +1,11 @@
from __future__ import unicode_literals
from django.apps import AppConfig
class TransportConfig(AppConfig):
name = 'modoboa.transport'
def ready(self):
from . import handlers
+105
View File
@@ -0,0 +1,105 @@
"""Transport backend."""
from django import forms
from django.core import validators
from django.utils.translation import ugettext_lazy as _
from modoboa.lib import validators as lib_validators
class TransportBackend(object):
"""Base backend class."""
name = None
settings = ()
def _validate_host_address(self, value):
"""Validate an host address syntax."""
validator_list = [
lib_validators.validate_hostname,
validators.validate_ipv46_address
]
for validator in validator_list:
try:
validator(value)
except forms.ValidationError:
pass
else:
return True
return False
def clean_fields(self, values):
"""Check that values are correct."""
errors = []
for setting in self.settings:
fname = "{}_{}".format(self.name, setting["name"])
value = values.get(fname)
if not value:
if not setting.get("required", True):
continue
errors.append((fname, _("This field is required")))
continue
ftype = setting.get("type", "string")
if ftype == "string":
validator = setting.get("validator")
vfunc = "_validate_{}".format(validator)
if validator and hasattr(self, vfunc):
if getattr(self, vfunc)(value):
continue
else:
continue
elif ftype == "int" and isinstance(value, int):
continue
elif ftype == "boolean" and isinstance(value, bool):
continue
errors.append((fname, _("Invalid value")))
return errors
def serialize(self, transport):
"""Transform received values if needed."""
pass
class TransportBackendManager(object):
"""Transport backends manager."""
def __init__(self):
"""Constructor."""
self.backends = {}
def get_backend(self, name, **kwargs):
"""Return backend instance."""
backend_class = self.backends.get(name)
if backend_class is None:
return None
return backend_class(**kwargs)
def get_backend_list(self):
"""Return known backend list."""
return sorted([
(key, key)
for key, backend in self.backends.items()
], key=lambda i: i[1])
def get_backend_settings(self, name):
"""Return backend settings."""
backend_class = self.backends.get(name)
if backend_class is None:
return None
return backend_class.settings
def get_all_backend_settings(self):
"""Return all backend settings."""
return {
name: backend_class.settings
for name, backend_class in list(self.backends.items())
}
def register_backend(self, backend_class):
"""Register a new backend."""
if not backend_class.name:
raise RuntimeError("missing backend name")
self.backends[backend_class.name] = backend_class
manager = TransportBackendManager()
+5
View File
@@ -0,0 +1,5 @@
"""Transport constants."""
PERMISSIONS = {
}
+20
View File
@@ -0,0 +1,20 @@
"""Transport factories."""
import factory
from modoboa.core.factories import PermissionFactory
from . import models
class TransportFactory(PermissionFactory):
"""Factory for Transport."""
class Meta:
model = models.Transport
django_get_or_create = ("pattern", )
pattern = factory.Sequence(lambda n: "transport{}".format(n))
service = "relay"
next_hop = "[external.host.tld]:25"
+94
View File
@@ -0,0 +1,94 @@
"""Transport forms."""
from django import forms
from . import backends
from . import models
TYPE_TO_FIELD_MAP = {
"int": forms.IntegerField,
"boolean": forms.BooleanField,
"string": forms.CharField
}
class BackendSettingsMixin(object):
"""A mixin to deal with backend settings in a model form."""
def __init__(self, *args, **kwargs):
"""Constructor."""
super(BackendSettingsMixin, self).__init__(*args, **kwargs)
self.setting_fields = []
def inject_backend_settings(self, name, settings):
"""Inject backend settings to form."""
for setting in settings:
fullname = "{}_{}".format(name, setting["name"])
options = {}
ftype = setting.get("type", "string")
if self.instance.pk:
options["initial"] = self.instance._settings.get(fullname)
elif "default" in setting:
options["initial"] = setting["default"]
if "widget" in setting:
options["widget"] = setting["widget"]
self.fields[fullname] = TYPE_TO_FIELD_MAP[ftype](
label=setting["label"], required=False, **options)
self.setting_fields.append(fullname)
def clean_backend_fields(self, name):
"""Clean backend fields."""
self.backend = backends.manager.get_backend(name)
for field, error in self.backend.clean_fields(self.cleaned_data):
self.add_error(field, error)
def save(self, commit=True):
"""Set settings to JSON field."""
transport = super(BackendSettingsMixin, self).save(commit=False)
transport._settings = {
name: self.cleaned_data[name] for name in self.setting_fields
}
if commit:
transport.save()
return transport
class TransportForm(BackendSettingsMixin, forms.ModelForm):
"""Transport model form."""
service = forms.ChoiceField(choices=[])
class Meta:
fields = ("pattern", "service", "enabled")
model = models.Transport
def __init__(self, *args, **kwargs):
"""Set backend list."""
super(TransportForm, self).__init__(*args, **kwargs)
self.fields["service"].choices = backends.manager.get_backend_list()
settings = backends.manager.get_all_backend_settings()
for name, backend_settings in settings.items():
self.inject_backend_settings(name, backend_settings)
def _clean_fields(self):
"""Make backend settings required."""
backend_name = self.data.get("service")
backend_settings = backends.manager.get_backend_settings(backend_name)
for name, field in self.fields.items():
if name.startswith("{}_".format(backend_name)):
name = name.replace("{}_".format(backend_name), "")
for setting in backend_settings:
if setting["name"] == name:
break
if setting.get("required", True):
field.required = True
return super(TransportForm, self)._clean_fields()
def clean(self):
"""Check values."""
cleaned_data = super(TransportForm, self).clean()
if self.errors:
return cleaned_data
self.clean_backend_fields(cleaned_data["service"])
return cleaned_data
+31
View File
@@ -0,0 +1,31 @@
"""Transport handlers."""
from __future__ import unicode_literals
from django.core.urlresolvers import reverse
from django.dispatch import receiver
from django.utils.translation import ugettext as _
from modoboa.admin import signals as admin_signals
from modoboa.core import signals as core_signals
from . import postfix_maps
@receiver(admin_signals.extra_domain_menu_entries)
def extra_domain_menu_entries(sender, user, **kwargs):
"""Add extra menu entries."""
return [{
"name": "transports",
"label": _("Transports"),
"url": reverse("transport:transport_list"),
"img": "fa fa-bus",
}]
@receiver(core_signals.register_postfix_maps)
def register_postfix_maps(sender, **kwargs):
"""Register postfix maps."""
return [
postfix_maps.TransportMap,
]
@@ -0,0 +1,124 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-11-24 16:42+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: backends.py:31
msgid "target host address"
msgstr ""
#: backends.py:32
msgid "target host port"
msgstr ""
#: backends.py:34
msgid "verify recipients"
msgstr ""
#: backends.py:46
msgid "This field is required"
msgstr ""
#: backends.py:54
msgid "Invalid value"
msgstr ""
#: handlers.py:20 templates/transport/transport_list.html:5
msgid "Transports"
msgstr ""
#: models.py:18
msgid "pattern"
msgstr ""
#: models.py:19
msgid "service"
msgstr ""
#: models.py:20
msgid "next hop"
msgstr ""
#: serializers.py:23
msgid "Unsupported service"
msgstr ""
#: templates/transport/_transport_headers.html:3
msgid "Name"
msgstr ""
#: templates/transport/_transport_headers.html:4
msgid "Tags"
msgstr ""
#: templates/transport/_transport_headers.html:5
#: templates/transport/transport_list.html:33
msgid "Actions"
msgstr ""
#: templates/transport/transport_list.html:21
msgid "Add"
msgstr ""
#: templates/transport/transport_list.html:31
msgid "Pattern"
msgstr ""
#: templates/transport/transport_list.html:32
msgid "Service"
msgstr ""
#: templates/transport/transport_list.html:39
msgid "disabled"
msgstr ""
#: templates/transport/transport_list.html:49
#, python-format
msgid "Delete %(pattern)s? "
msgstr ""
#: templates/transport/transport_list.html:56
msgid "No transport defined yet."
msgstr ""
#: views.py:47
msgid "Transport created"
msgstr ""
#: views.py:99
msgid "Create"
msgstr ""
#: views.py:100
msgid "New transport"
msgstr ""
#: views.py:112
msgid "Transport updated"
msgstr ""
#: views.py:121
msgid "Update"
msgstr ""
#: views.py:122
msgid "Edit transport"
msgstr ""
#: views.py:141
msgid "Transport deleted"
msgstr ""
@@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-11-24 16:44+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: static/transport/js/transport.js:36
msgid "Search"
msgstr ""
@@ -0,0 +1,124 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-11-24 16:42+0100\n"
"PO-Revision-Date: 2017-11-24 17:22+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: backends.py:31
msgid "target host address"
msgstr "adresse de l'hôte cible"
#: backends.py:32
msgid "target host port"
msgstr "admin/tests/"
#: backends.py:34
msgid "verify recipients"
msgstr "vérifier les destinataires"
#: backends.py:46
msgid "This field is required"
msgstr "Ce champ est requis"
#: backends.py:54
msgid "Invalid value"
msgstr "Valeur incorrecte"
#: handlers.py:20 templates/transport/transport_list.html:5
msgid "Transports"
msgstr "Transports"
#: models.py:18
msgid "pattern"
msgstr "patron"
#: models.py:19
msgid "service"
msgstr "service"
#: models.py:20
msgid "next hop"
msgstr "saut suivant"
#: serializers.py:23
msgid "Unsupported service"
msgstr "Service non supporté"
#: templates/transport/_transport_headers.html:3
msgid "Name"
msgstr "Nom"
#: templates/transport/_transport_headers.html:4
msgid "Tags"
msgstr "Libellés"
#: templates/transport/_transport_headers.html:5
#: templates/transport/transport_list.html:33
msgid "Actions"
msgstr "Actions"
#: templates/transport/transport_list.html:21
msgid "Add"
msgstr "Ajouter"
#: templates/transport/transport_list.html:31
msgid "Pattern"
msgstr "Patron"
#: templates/transport/transport_list.html:32
msgid "Service"
msgstr "Service"
#: templates/transport/transport_list.html:39
msgid "disabled"
msgstr "désactivé"
#: templates/transport/transport_list.html:49
#, python-format
msgid "Delete %(pattern)s? "
msgstr "Supprimer %(pattern)s ? "
#: templates/transport/transport_list.html:56
msgid "No transport defined yet."
msgstr "Aucun transport défini"
#: views.py:47
msgid "Transport created"
msgstr "Transport créé"
#: views.py:99
msgid "Create"
msgstr "Créer"
#: views.py:100
msgid "New transport"
msgstr "Nouveau transport"
#: views.py:112
msgid "Transport updated"
msgstr "Transport mis à jour"
#: views.py:121
msgid "Update"
msgstr "Mettre à jour"
#: views.py:122
msgid "Edit transport"
msgstr "Modifier le transport"
#: views.py:141
msgid "Transport deleted"
msgstr "Transport supprimé"
@@ -0,0 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-11-24 16:44+0100\n"
"PO-Revision-Date: 2017-11-24 16:45+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: static/transport/js/transport.js:36
msgid "Search"
msgstr "Chercher"
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2017-11-24 11:16
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
import jsonfield.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Transport',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation', models.DateTimeField(default=django.utils.timezone.now)),
('last_modification', models.DateTimeField(auto_now=True)),
('pattern', models.CharField(max_length=254, unique=True, verbose_name='pattern')),
('service', models.CharField(max_length=30, verbose_name='service')),
('next_hop', models.CharField(blank=True, max_length=100, verbose_name='next hop')),
('enabled', models.BooleanField(default=True, verbose_name='enabled')),
('_settings', jsonfield.fields.JSONField(default={})),
],
options={
'ordering': ['pattern'],
},
),
]
+32
View File
@@ -0,0 +1,32 @@
"""Transport models."""
from __future__ import unicode_literals
from reversion import revisions as reversion
from django.db import models
from django.utils.translation import ugettext_lazy as _
import jsonfield
from modoboa.admin import models as admin_models
class Transport(admin_models.AdminObject):
"""Transport table."""
pattern = models.CharField(_("pattern"), unique=True, max_length=254)
service = models.CharField(_("service"), max_length=30)
next_hop = models.CharField(_("next hop"), max_length=100, blank=True)
enabled = models.BooleanField("enabled", default=True)
_settings = jsonfield.JSONField(default={})
class Meta:
ordering = ["pattern"]
def __str__(self):
return self.pattern
reversion.register(Transport)
+24
View File
@@ -0,0 +1,24 @@
"""Map file definitions for postfix."""
from __future__ import unicode_literals
class TransportMap(object):
"""A transport map."""
filename = "sql-transport.cf"
mysql = (
"SELECT CONCAT(service, ':', next_hop) "
"FROM transport_transport "
"WHERE enabled=1 AND pattern='%s'"
)
postgres = (
"SELECT service || ':' || next_hop "
"FROM transport_transport "
"WHERE enabled AND pattern='%s'"
)
sqlite = (
"SELECT service || ':' || next_hop "
"FROM transport_transport "
"WHERE enabled=1 AND pattern='%s'"
)
+49
View File
@@ -0,0 +1,49 @@
"""Transport serializers."""
import json
from django.utils.translation import ugettext as _
from rest_framework import serializers
from . import backends
from . import models
class TransportSerializer(serializers.ModelSerializer):
"""Transport serializer class."""
class Meta:
model = models.Transport
fields = ("pk", "pattern", "service", "enabled", "_settings")
def validate(self, data):
"""Check fields based on backend."""
self.backend = backends.manager.get_backend(data["service"])
if not self.backend:
raise serializers.ValidationError({
"service": _("Unsupported service")
})
data["_settings"] = json.loads(data["_settings"])
errors = self.backend.clean_fields(data["_settings"])
if errors:
raise serializers.ValidationError({
"_settings": ",".join(
["{}: {}".format(error[0], error[1]) for error in errors])
})
return data
def create(self, validated_data):
"""Use backend to serialize data."""
transport = models.Transport(**validated_data)
self.backend.serialize(transport)
transport.save(creator=self.context["request"].user)
return transport
def update(self, instance, validated_data):
"""Use backend to serialize data."""
for key, value in validated_data.items():
setattr(instance, key, value)
self.backend.serialize(instance)
instance.save()
return instance
@@ -0,0 +1,43 @@
/*
* Transport javascript tools.
*/
function displayBackendSettings(name) {
$('#backend_settings').find("div[class*=form-group]").hide();
if (name !== '') {
$('#backend_settings').find('[name*=' + name + '_]').each(function (index, input) {
$(input).parents("div[class*=form-group]").show();
});
}
}
function transportFormCallback () {
var $service_field = $('#id_service');
$service_field.change(function (event) {
displayBackendSettings($(this).val());
});
displayBackendSettings($service_field.val());
$('.submit').one('click', $.proxy(function(e) {
simple_ajax_form_post(e, {
formid: 'transport_form',
error_cb: transportFormCallback
});
}, this));
}
$(document).ready(function () {
$('#searchquery').focus(function () {
$(this).val('');
}).blur(function (e) {
var $this = $(this);
if ($this.val() === '') {
$this.val(gettext('Search'));
}
});
$('a[name=deltransport]').confirm({
question: function() { return this.$element.attr('title'); },
method: 'DELETE'
});
});
@@ -0,0 +1,14 @@
{% extends "common/generic_modal_form.html" %}
{% load form_tags %}
{% block formbody %}
{% render_field form.pattern %}
{% render_field form.service %}
{% render_field form.enabled %}
<div id="backend_settings">
{% for field in setting_fields %}
{% render_field field hidden=True %}
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,5 @@
{% load i18n %}
<th id="name" class="sortable" data-sort_order="name" width="">{% trans "Name" %}</th>
<th id="tags" class="sortable" data-sort_order="tags" width="">{% trans "Tags" %}</th>
<th id="actions" width="">{% trans "Actions" %}</th>
@@ -0,0 +1,66 @@
{% extends "twocols.html" %}
{% load i18n admin_tags lib_tags static %}
{% block pagetitle %}{% trans "Transports" %}{% endblock %}
{% block leftcol %}
<ul class="nav nav-sidebar">
{% domains_menu "transports" user False %}
</ul>
{% endblock %}
{% block apparea %}
<div class="row">
<div class="col-sm-5">
{% include "admin/searchform.html" %}
</div>
<div class="col-sm-3">
{% if perms.transport.add_transport %}
<a href="{% url 'transport:transport_create' %}" class="btn btn-primary" data-toggle="ajaxmodal" modalcb="transportFormCallback">
<span class="fa fa-plus"></span> {% trans "Add" %}
</a>
{% endif %}
</div>
<div id="taglist" class="col-sm-4"> </div>
</div>
<table class="table table-hover">
<thead>
<tr>
<th id="pattern" class="sortable" data-sort_order="pattern" width="">{% trans "Pattern" %}</th>
<th id="service" class="sortable" data-sort_order="service" width="">{% trans "Service" %}</th>
<th id="actions" width="">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for transport in object_list %}
<tr class="{% if not transport.enabled %}text-muted{% endif %}">
<td>{{ transport.pattern }}{% if not transport.enabled %} ({% trans "disabled" %}){% endif %}</td>
<td><span class="label label-default">
<a href="#" class="filter transport">{{ transport.service }}</a>
</span></td>
<td name="actions">
<a href="{% url 'transport:transport_update' transport.pk %}"
data-toggle="ajaxmodal" modalcb="transportFormCallback">
<span class="fa fa-edit"></span>
</a>
<a href="{% url 'transport:transport_delete' transport.pk %}"
name="deltransport" title="{% blocktrans with pattern=transport.pattern %}Delete {{ pattern }}? {% endblocktrans %}">
<span class="fa fa-trash"></span>
</a>
</td>
</tr>
{% empty %}
<tr><td colspan="3">
<div class="alert alert-info">{% trans "No transport defined yet." %}</div>
</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block extra_js %}
{{ block.super }}
<script type="text/javascript" src="{% static 'transport/js/transport.js' %}"></script>
{% endblock %}
+163
View File
@@ -0,0 +1,163 @@
"""Transport tests."""
import json
from django.core.urlresolvers import reverse
from modoboa.lib.tests import ModoAPITestCase, ModoTestCase
from . import factories
from . import models
class DataMixin(object):
"""A mixin to provide test data."""
@classmethod
def setUpTestData(cls):
super(DataMixin, cls).setUpTestData()
cls.transport1 = factories.TransportFactory(
pattern="domain1.test", _settings={
"relay_target_host": "external.host.tld",
"relay_target_port": 25,
"relay_verify_recipients": False
}
)
cls.transport2 = factories.TransportFactory(
pattern="domain2.test", _settings={
"relay_target_host": "external.host.tld",
"relay_target_port": 25,
"relay_verify_recipients": True
}
)
class TransportViewsTestCase(DataMixin, ModoTestCase):
"""Test transport views."""
def test_transport_list(self):
"""Test list view."""
url = reverse("transport:transport_list")
response = self.client.get(url)
self.assertContains(response, self.transport1.pattern)
self.assertContains(response, self.transport2.pattern)
url = "{}?searchquery=domain1".format(url)
response = self.client.get(url)
self.assertContains(response, self.transport1.pattern)
self.assertNotContains(response, self.transport2.pattern)
def test_create_transport(self):
"""Test create view."""
url = reverse("transport:transport_create")
response = self.client.get(url)
self.assertContains(response, "New transport")
data = {
"pattern": "domain3.test",
"service": "relay",
"relay_target_host": "1.2.3.4",
}
response = self.ajax_post(url, data, 400)
data.update({"relay_target_port": 25})
self.ajax_post(url, data)
transport = models.Transport.objects.get(pattern="domain3.test")
self.assertEqual(
transport.next_hop, "[{}]:{}".format(
data["relay_target_host"], data["relay_target_port"])
)
def test_update_transport(self):
"""Test update view."""
url = reverse("transport:transport_update", args=[self.transport1.pk])
response = self.client.get(url)
self.assertContains(response, "Edit transport")
data = {
"pattern": self.transport1.pattern,
"service": self.transport1.service,
"relay_target_host": "1.2.3.5",
"relay_target_port": 234,
"relay_verify_recipients": True
}
self.ajax_post(url, data)
self.transport1.refresh_from_db()
self.assertTrue(
self.transport1._settings["relay_verify_recipients"])
self.assertEqual(
self.transport1.next_hop, "[{}]:{}".format(
data["relay_target_host"], data["relay_target_port"])
)
def test_delete_transport(self):
"""Test delete view."""
url = reverse("transport:transport_delete", args=[self.transport1.pk])
response = self.client.delete(url)
self.assertEqual(response.status_code, 302)
with self.assertRaises(models.Transport.DoesNotExist):
self.transport1.refresh_from_db()
class TransportAPITestCase(DataMixin, ModoAPITestCase):
"""API test cases."""
def test_list(self):
"""Test list service."""
url = reverse("api:transport-list")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 2)
def test_create(self):
"""Test create service."""
url = reverse("api:transport-list")
settings = {
"relay_target_host": "1.2.3.4"
}
data = {
"pattern": "domain3.test",
"service": "relay",
"_settings": json.dumps(settings)
}
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 400)
self.assertEqual(
response.data["_settings"],
["relay_target_port: This field is required"]
)
settings.update({"relay_target_port": 25})
data["_settings"] = json.dumps(settings)
response = self.client.post(url, data, format="json")
self.assertEqual(response.status_code, 201)
transport = models.Transport.objects.get(pattern="domain3.test")
self.assertEqual(
transport.next_hop, "[{}]:{}".format(
settings["relay_target_host"], settings["relay_target_port"])
)
def test_update(self):
"""Test update service."""
url = reverse("api:transport-detail", args=[self.transport1.pk])
settings = self.transport1._settings.copy()
settings.update({"relay_target_port": 1000})
data = {
"pattern": "domain3.test",
"service": "relay",
"_settings": json.dumps(settings)
}
response = self.client.put(url, data, format="json")
self.assertEqual(response.status_code, 200)
self.transport1.refresh_from_db()
self.assertEqual(self.transport1.pattern, data["pattern"])
self.assertEqual(
self.transport1.next_hop, "[{}]:{}".format(
settings["relay_target_host"], settings["relay_target_port"])
)
def test_delete(self):
"""Test delete service."""
url = reverse("api:transport-detail", args=[self.transport1.pk])
response = self.client.delete(url)
self.assertEqual(response.status_code, 204)
with self.assertRaises(models.Transport.DoesNotExist):
self.transport1.refresh_from_db()
+17
View File
@@ -0,0 +1,17 @@
"""Transport urls."""
from django.conf.urls import url
from . import views
urlpatterns = [
url(r"^$", views.TransportListView.as_view(),
name="transport_list"),
url("^create/$", views.TransportCreateView.as_view(),
name="transport_create"),
url("^update/(?P<pk>\d+)/$", views.TransportUpdateView.as_view(),
name="transport_update"),
url("^delete/(?P<pk>\d+)/$", views.TransportDeleteView.as_view(),
name="transport_delete"),
]
+11
View File
@@ -0,0 +1,11 @@
"""Transport API urls."""
from rest_framework import routers
from . import viewsets
router = routers.SimpleRouter()
router.register(
r"transports", viewsets.TransportViewSet, base_name="transport")
urlpatterns = router.urls
+142
View File
@@ -0,0 +1,142 @@
"""Transport views."""
from django import http
from django.core.urlresolvers import reverse, reverse_lazy
from django.utils.translation import ugettext as _, ugettext_lazy
from django.views import generic
from django.contrib.auth import mixins as auth_mixins
from django.contrib import messages
from . import backends
from . import forms
from . import models
class TransportListView(
auth_mixins.PermissionRequiredMixin, generic.ListView):
"""ListView for Transport."""
model = models.Transport
permission_required = "transport.add_transport"
template_name = "transport/transport_list.html"
def get_context_data(self, **kwargs):
"""Inject search query."""
context = super(TransportListView, self).get_context_data(**kwargs)
context.update({
"selection": "domains",
"squery": self.request.GET.get("searchquery")
})
return context
def get_queryset(self):
"""Filter."""
qs = super(TransportListView, self).get_queryset()
searchquery = self.request.GET.get("searchquery")
if searchquery:
qs = qs.filter(pattern__icontains=searchquery)
return qs
class TransportMixin(object):
"""Mixin for Transport creation/update."""
form_class = forms.TransportForm
model = models.Transport
success_message = ugettext_lazy("Transport created")
success_url = reverse_lazy("transport:transport_list")
template_name = "transport/_transport_form.html"
def form_invalid(self, form):
"""Return JSON response."""
return http.JsonResponse({
"form_errors": form.errors,
"prefix": form.prefix
}, status=400)
def form_valid(self, form):
"""Save transport."""
transport = form.save(commit=False)
backend = backends.manager.get_backend(transport.service)
backend.serialize(transport)
options = {}
if not transport.pk:
options.update({"creator": self.request.user})
transport.save(**options)
messages.success(self.request, self.success_message)
return http.JsonResponse({})
def get_context_data(self, **kwargs):
"""Include extra data."""
context = super(TransportMixin, self).get_context_data(
**kwargs)
setting_fields = []
for fname in context["form"].setting_fields:
setting_fields.append(context["form"][fname])
context.update({
"action_classes": "submit",
"formid": "transport_form",
"setting_fields": setting_fields
})
return context
class TransportCreateView(
auth_mixins.PermissionRequiredMixin,
TransportMixin,
generic.CreateView):
"""Transport create view."""
permission_required = "transport.add_transport"
def get_context_data(self, **kwargs):
"""Include extra data."""
context = super(TransportCreateView, self).get_context_data(
**kwargs)
context.update({
"action": reverse("transport:transport_create"),
"action_label": _("Create"),
"title": _("New transport"),
})
return context
class TransportUpdateView(
auth_mixins.PermissionRequiredMixin,
TransportMixin,
generic.UpdateView):
"""Transport update view."""
permission_required = "transport.change_transport"
success_message = ugettext_lazy("Transport updated")
def get_context_data(self, **kwargs):
"""Include extra data."""
context = super(TransportUpdateView, self).get_context_data(
**kwargs)
context.update({
"action": reverse(
"transport:transport_update", args=[self.object.pk]),
"action_label": _("Update"),
"title": _("Edit transport"),
})
return context
class TransportDeleteView(
auth_mixins.PermissionRequiredMixin,
TransportMixin,
generic.DeleteView):
"""Transport delete view."""
model = models.Transport
permission_required = "transport.del_transport"
success_url = reverse_lazy("transport:transport_list")
def delete(self, request, *args, **kwargs):
"""Add message."""
response = super(TransportDeleteView, self).delete(
request, *args, **kwargs)
messages.success(request, _("Transport deleted"))
return response
+18
View File
@@ -0,0 +1,18 @@
"""Transport viewsets."""
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated, DjangoModelPermissions
from . import models
from . import serializers
class TransportViewSet(viewsets.ModelViewSet):
"""Transport viewset."""
permission_classes = [IsAuthenticated, DjangoModelPermissions, ]
serializer_class = serializers.TransportSerializer
def get_queryset(self):
"""Filter queryset based on current user."""
return models.Transport.objects.get_for_admin(self.request.user)
+2 -2
View File
@@ -28,8 +28,8 @@ urlpatterns = [
url('', include('modoboa.core.urls', namespace="core")),
url('^user/forward/', forward, name="user_forward"),
url('admin/', include('modoboa.admin.urls', namespace="admin")),
url('relaydomains/',
include('modoboa.relaydomains.urls', namespace="relaydomains")),
url(r'^transports/',
include("modoboa.transport.urls", namespace="transport")),
# No namespace
url(r'^accounts/password_reset/$', core_views.password_reset,
name="password_reset"),
+1
View File
@@ -10,5 +10,6 @@ from modoboa.core.extensions import exts_pool
urlpatterns = [
url("", include("modoboa.admin.urls_api")),
url("", include("modoboa.limits.urls_api")),
url("", include("modoboa.transport.urls_api")),
]
urlpatterns += exts_pool.get_urls(category="api")
+3 -2
View File
@@ -1,4 +1,5 @@
django>=1.10,<=1.10.99
django-braces==1.11.0
django-ckeditor==5.2.1
django-reversion==1.10.2
django-subcommand2==0.1.1
@@ -13,12 +14,12 @@ djangorestframework==3.7.1
argparse
# bcrypt requires libffi-dev
bcrypt==3.1.3
bcrypt==3.1.4
dnspython==1.15.0
feedparser==5.2.1
gevent==1.2.1
ipaddress
jsonfield==1.0.3
jsonfield==2.0.2
passlib==1.7.1
progressbar33==2.4
py-dateutil==2.2
+1
View File
@@ -60,6 +60,7 @@ MODOBOA_APPS = (
'modoboa.lib',
'modoboa.admin',
'modoboa.limits',
'modoboa.transport',
'modoboa.relaydomains',
'modoboa.parameters',
)