Merge pull request #2478 from modoboa/feature/transport_crud

WIP: relaydomain CRUD in new admin
This commit is contained in:
Antoine Nguyen
2022-03-25 11:04:26 +01:00
committed by GitHub
16 changed files with 390 additions and 31 deletions
+9
View File
@@ -0,0 +1,9 @@
import repository from './repository'
const resource = 'transports'
export default {
getAll () {
return repository.get(`/${resource}/`)
}
}
@@ -20,6 +20,9 @@
<template v-slot:form.options="{ step }">
<domain-options-form :ref="`form_${step}`" :domain="domain" @createAdmin="updateCreateAdmin" />
</template>
<template v-slot:form.transport="{ step }">
<domain-transport-form :ref="`form_${step}`" :domain="domain" />
</template>
<template v-slot:item.with_random_password="{ item }">
<template v-if="item.value">
<v-col cols="12" class="highligth white--text">
@@ -52,6 +55,7 @@ import DomainDNSForm from './DomainDNSForm'
import DomainGeneralForm from './DomainGeneralForm'
import DomainLimitationsForm from './DomainLimitationsForm'
import DomainOptionsForm from './DomainOptionsForm'
import DomainTransportForm from './DomainTransportForm'
export default {
props: ['value'],
@@ -60,7 +64,8 @@ export default {
'domain-dns-form': DomainDNSForm,
DomainGeneralForm,
DomainLimitationsForm,
DomainOptionsForm
DomainOptionsForm,
DomainTransportForm
},
computed: {
summarySections () {
@@ -87,12 +92,6 @@ export default {
{ key: this.$gettext('Default mailbox quota'), value: this.domain.default_mailbox_quota },
{ key: this.$gettext('Message sending limit'), value: this.domain.message_sending_limit }
]
},
{
title: this.$gettext('Options'),
items: [
{ key: this.$gettext('Create a domain administrator'), value: this.createAdmin, type: 'yesno' }
]
}
]
if (this.domain.enable_dkim) {
@@ -100,25 +99,53 @@ export default {
result[1].items.push({ key: this.$gettext('DKIM key length'), value: this.domain.dkim_key_length })
}
if (this.createAdmin) {
result[3].items.push({ key: this.$gettext('Administrator name'), value: this.domain.domain_admin.username })
result[3].items.push({
name: 'with_random_password',
key: this.$gettext('Random password'),
value: this.domain.domain_admin.with_random_password,
type: 'yesno'
})
result[3].items.push({
key: this.$gettext('With mailbox'),
value: this.domain.domain_admin.with_mailbox,
type: 'yesno'
})
result[3].items.push({
key: this.$gettext('Create aliases'),
value: this.domain.domain_admin.with_aliases,
type: 'yesno'
result.push({
title: this.$gettext('Options'),
items: [
{ key: this.$gettext('Create a domain administrator'), value: this.createAdmin, type: 'yesno' },
{ key: this.$gettext('Administrator name'), value: this.domain.domain_admin.username },
{
name: 'with_random_password',
key: this.$gettext('Random password'),
value: this.domain.domain_admin.with_random_password,
type: 'yesno'
},
{
key: this.$gettext('With mailbox'),
value: this.domain.domain_admin.with_mailbox,
type: 'yesno'
},
{
key: this.$gettext('Create aliases'),
value: this.domain.domain_admin.with_aliases,
type: 'yesno'
}
]
})
}
if (this.domain.type === 'relaydomain') {
const relayEntry = {
title: this.$gettext('Transport'),
items: [
{ key: this.$gettext('Service'), value: this.domain.transport.service }
]
}
if (this.$refs.form_4.service) {
const service = this.$refs.form_4.service
for (const setting of service.settings) {
const item = { key: setting.label, value: this.domain.transport.settings[`${service.name}_${setting.name}`] }
if (setting.type === 'boolean') {
item.type = 'yesno'
}
relayEntry.items.push(item)
}
}
result.push(relayEntry)
}
return result
},
steps () {
return this.domain.type === 'domain' ? this.domainSteps : this.relaySteps
}
},
data () {
@@ -128,11 +155,17 @@ export default {
domain_admin: {}
},
formErrors: {},
steps: [
domainSteps: [
{ name: 'general', title: this.$gettext('General') },
{ name: 'dns', title: this.$gettext('DNS') },
{ name: 'limitations', title: this.$gettext('Limitations') },
{ name: 'options', title: this.$gettext('Options') }
],
relaySteps: [
{ name: 'general', title: this.$gettext('General') },
{ name: 'dns', title: this.$gettext('DNS') },
{ name: 'limitations', title: this.$gettext('Limitations') },
{ name: 'transport', title: this.$gettext('Transport') }
]
}
},
@@ -160,7 +193,8 @@ export default {
with_random_password: false,
with_mailbox: false,
with_aliases: false
}
},
transport: {}
}
},
close () {
@@ -181,6 +215,9 @@ export default {
if (!this.createAdmin) {
delete data.domain_admin
}
if (data.type === 'relaydomain') {
this.$refs.form_4.checkSettingTypes(data)
}
this.$store.dispatch('domains/createDomain', data).then(resp => {
this.$router.push({ name: 'DomainDetail', params: { id: resp.data.pk } })
bus.$emit('notification', { msg: this.$gettext('Domain created') })
+40 -1
View File
@@ -104,6 +104,35 @@
<domain-limitations-form :domain="editedDomain" />
</v-expansion-panel-content>
</v-expansion-panel>
<v-expansion-panel v-if="editedDomain.type === 'relaydomain'">
<v-expansion-panel-header v-slot="{ open }">
<v-row no-gutters>
<v-col cols="4">
<translate>Transport</translate>
</v-col>
<v-col
cols="8"
class="text--secondary"
>
<v-fade-transition leave-absolute>
<span v-if="open"></span>
<v-row
v-else
no-gutters
style="width: 100%"
>
<v-col cols="6">
<translate class="mr-2">Service: </translate> {{ editedDomain.transport.service }}
</v-col>
</v-row>
</v-fade-transition>
</v-col>
</v-row>
</v-expansion-panel-header>
<v-expansion-panel-content>
<domain-transport-form ref="transportForm" :domain="editedDomain" />
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
<div class="mt-4 d-flex justify-end">
<v-btn color="grey lighten-1" @click="$router.go(-1)">
@@ -121,12 +150,14 @@ import { bus } from '@/main'
import DomainDNSForm from './DomainDNSForm'
import DomainGeneralForm from './DomainGeneralForm'
import DomainLimitationsForm from './DomainLimitationsForm'
import DomainTransportForm from './DomainTransportForm'
export default {
components: {
'domain-dns-form': DomainDNSForm,
DomainGeneralForm,
DomainLimitationsForm
DomainLimitationsForm,
DomainTransportForm
},
props: ['domain'],
data () {
@@ -143,6 +174,9 @@ export default {
return
}
}
if (this.editedDomain.type === 'relaydomain') {
this.$refs.transportForm.checkSettingTypes(this.editedDomain)
}
this.$store.dispatch('domains/updateDomain', this.editedDomain).then(resp => {
bus.$emit('notification', { msg: this.$gettext('Domain updated') })
})
@@ -156,6 +190,11 @@ export default {
}
},
immediate: true
},
'editedDomain.type' (val) {
if (val === 'relaydomain' && this.editedDomain.transport === null) {
this.$set(this.editedDomain, 'transport', {})
}
}
}
}
@@ -42,6 +42,14 @@
<router-link :to="{ name: 'DomainDetail', params: { id: item.pk } }">
{{ item.name }}
</router-link>
<v-chip
v-if="item.type === 'relaydomain'"
small
color="primary"
class="ml-2"
>
<translate>Relay</translate>
</v-chip>
<translate v-if="!item.enabled" class="ml-2 grey--text">(disabled)</translate>
</td>
<td>
@@ -0,0 +1,104 @@
<template>
<div>
<validation-observer ref="observer">
<label class="m-label"><translate>Service</translate></label>
<validation-provider
v-slot="{ errors }"
rules="required"
>
<v-autocomplete
v-model="service"
:items="backends"
item-text="name"
return-object
outlined
@change="resetForm"
:error-messages="errors"
/>
</validation-provider>
<template v-if="service">
<template v-for="setting in service.settings">
<div :key="setting.name">
<validation-provider
v-slot="{ errors }"
:rules="setting.required ? 'required' : ''"
>
<template
v-if="setting.type === 'str' || setting.type === 'int'"
>
<label class="m-label">{{ setting.label }}</label>
<v-text-field
v-model="domain.transport.settings[`${service.name}_${setting.name}`]"
outlined
:error-messages="errors"
:type="setting.type === 'int' ? 'number' : 'text'"
/>
</template>
<v-checkbox
v-else-if="setting.type === 'boolean'"
v-model="domain.transport.settings[`${service.name}_${setting.name}`]"
:label="setting.label"
:error-messages="errors"
/>
</validation-provider>
</div>
</template>
</template>
</validation-observer>
</div>
</template>
<script>
import transports from '@/api/transports'
export default {
props: {
domain: Object
},
data () {
return {
backends: [],
service: null
}
},
methods: {
resetForm (value) {
this.$set(this.domain, 'transport', { service: value.name, settings: {} })
for (const setting of value.settings) {
if (setting.default) {
let defaultValue = setting.default
if (setting.type === 'int') {
defaultValue = parseInt(defaultValue)
} else if (setting.type === 'boolean') {
if (defaultValue === 'False') {
defaultValue = false
} else {
defaultValue = true
}
}
this.$set(this.domain.transport.settings, `${value.name}_${setting.name}`, defaultValue)
}
}
},
checkSettingTypes (data) {
for (const setting of this.service.settings) {
if (setting.type === 'int') {
const fullName = `${this.service.name}_${setting.name}`
data.transport.settings[fullName] = parseInt(data.transport.settings[fullName])
}
}
}
},
mounted () {
transports.getAll().then(resp => {
this.backends = resp.data
if (this.domain) {
this.service = this.backends.find(backend => backend.name === this.domain.transport.service)
}
})
},
watch: {
}
}
</script>
+41 -2
View File
@@ -18,6 +18,8 @@ from modoboa.lib import fields as lib_fields
from modoboa.lib import validators, web_utils
from modoboa.lib.sysutils import exec_cmd
from modoboa.parameters import tools as param_tools
from modoboa.transport import models as transport_models
from modoboa.transport.api.v2 import serializers as transport_serializers
from ... import constants, models
@@ -34,17 +36,33 @@ class CreateDomainAdminSerializer(serializers.Serializer):
class DomainSerializer(v1_serializers.DomainSerializer):
"""Domain serializer for v2 API."""
domain_admin = CreateDomainAdminSerializer(required=False)
domain_admin = CreateDomainAdminSerializer(required=False, write_only=True)
transport = transport_serializers.TransportSerializer(required=False)
class Meta(v1_serializers.DomainSerializer.Meta):
fields = v1_serializers.DomainSerializer.Meta.fields + (
"domain_admin",
"domain_admin", "transport"
)
def validate(self, data):
result = super().validate(data)
dtype = data.get("type", "domain")
if dtype == "relaydomain" and not data.get("transport"):
raise serializers.ValidationError({"transport": _("This field is required")})
return result
def create(self, validated_data):
"""Create administrator and other stuff if needed."""
domain_admin = validated_data.pop("domain_admin", None)
transport_def = validated_data.pop("transport", None)
domain = super().create(validated_data)
if transport_def:
transport = transport_models.Transport(**transport_def)
transport.pattern = domain.name
transport.save()
domain.transport = transport
domain.save()
if not domain_admin:
return domain
@@ -99,6 +117,27 @@ class DomainSerializer(v1_serializers.DomainSerializer):
domain.add_admin(da)
return domain
def update(self, instance, validated_data):
"""Update domain and create/update transport."""
transport_def = validated_data.pop("transport", None)
super().update(instance, validated_data)
if transport_def and instance.type == "relaydomain":
transport = getattr(instance, "transport", None)
created = False
if transport:
for key, value in transport_def.items():
setattr(instance, key, value)
transport._settings = transport_def["_settings"]
else:
transport = transport_models.Transport(**transport_def)
created = True
transport.pattern = instance.name
transport.save()
if created:
instance.transport = transport
instance.save()
return instance
class DeleteDomainSerializer(serializers.Serializer):
"""Serializer used with delete operation."""
+27
View File
@@ -41,6 +41,33 @@ class DomainViewSetTestCase(ModoAPITestCase):
address="postmaster@domain.tld").exists()
)
def test_update(self):
domain = models.Domain.objects.get(name="test2.com")
data = {
"name": "test2.com",
"type": "relaydomain",
"transport": {
"service": "relay",
"settings": {
"relay_target_port": 25,
"relay_target_host": "localhost"
}
}
}
url = reverse("v2:domain-detail", args=[domain.pk])
resp = self.client.put(url, data, format="json")
self.assertEqual(resp.status_code, 200)
domain.refresh_from_db()
self.assertEqual(domain.transport.service, data["transport"]["service"])
self.assertEqual(
domain.transport._settings["relay_target_host"],
data["transport"]["settings"]["relay_target_host"]
)
data["transport"]["relay_verify_recipients"] = True
resp = self.client.put(url, data, format="json")
self.assertEqual(resp.status_code, 200)
def test_delete(self):
self.client.credentials(
HTTP_AUTHORIZATION="Token " + self.da_token.key)
+1 -3
View File
@@ -58,15 +58,13 @@ class DomainViewSet(lib_viewsets.RevisionModelMixin,
return models.Domain.objects.get_for_admin(self.request.user)
def get_serializer_class(self, *args, **kwargs):
if self.action == "create":
return serializers.DomainSerializer
if self.action == "delete":
return serializers.DeleteDomainSerializer
if self.action == "administrators":
return serializers.DomainAdminSerializer
if self.action in ["add_administrator", "remove_administrator"]:
return serializers.SimpleDomainAdminSerializer
return v1_serializers.DomainSerializer
return serializers.DomainSerializer
@action(methods=["post"], detail=True)
def delete(self, request, **kwargs):
View File
+42
View File
@@ -0,0 +1,42 @@
"""Transport related serializers."""
from rest_framework import serializers
from ... import backends
from ... import models
class TransportBackendSettingSerializer(serializers.Serializer):
"""Serializer for transport backend setting."""
name = serializers.CharField()
label = serializers.CharField()
type = serializers.CharField(default="str")
required = serializers.BooleanField(default=True)
default = serializers.CharField(default=None)
class TransportBackendSerializer(serializers.Serializer):
"""Serializer for transport backend."""
name = serializers.CharField()
settings = TransportBackendSettingSerializer(many=True)
class TransportSerializer(serializers.ModelSerializer):
"""Serializer for Transport model."""
settings = serializers.DictField(source="_settings")
class Meta:
fields = ("service", "settings")
model = models.Transport
def validate(self, data):
self.backend = backends.manager.get_backend(data["service"])
errors = {}
for field, error in self.backend.clean_fields(data["_settings"]):
errors[field] = error
if errors:
raise serializers.ValidationError(errors)
return data
+16
View File
@@ -0,0 +1,16 @@
"""API v2 tests."""
from django.urls import reverse
from modoboa.lib.tests import ModoAPITestCase
class TransportViewSetTestCase(ModoAPITestCase):
def test_list(self):
url = reverse("v2:transport-list")
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
backends = resp.json()
self.assertEqual(len(backends), 1)
self.assertEqual(backends[0]["name"], "relay")
+12
View File
@@ -0,0 +1,12 @@
"""App related urls."""
from rest_framework import routers
from . import viewsets
router = routers.SimpleRouter()
router.register(
r"transports", viewsets.TransportViewSet, basename="transport")
urlpatterns = router.urls
+27
View File
@@ -0,0 +1,27 @@
"""Transport related viewsets."""
from drf_spectacular.utils import extend_schema
from rest_framework import permissions, response, viewsets
from . import serializers
from ... import backends
class TransportViewSet(viewsets.ViewSet):
"""Viewset for Transport."""
permissions = (permissions.IsAuthenticated, )
@extend_schema(
responses={200: serializers.TransportBackendSerializer}
)
def list(self, request, *args, **kwargs):
"""Return the list of all defined transport backends."""
all_settings = backends.manager.get_all_backend_settings()
result = []
for name, settings in all_settings.items():
serializer = serializers.TransportBackendSerializer(
{"name": name, "settings": settings}
)
result.append(serializer.data)
return response.Response(result)
+1
View File
@@ -14,6 +14,7 @@ urlpatterns = [
path('', include("modoboa.relaydomains.api.v1.urls")),
path('', include("modoboa.dnstools.api.v2.urls")),
path('', include("modoboa.maillog.api.v2.urls")),
path('', include("modoboa.transport.api.v2.urls")),
]
urlpatterns += exts_pool.get_urls(category="api")