Added account creation wizard.

This commit is contained in:
Antoine Nguyen
2021-04-15 18:45:48 +02:00
parent 043763af9a
commit 8e48ed1bd4
18 changed files with 813 additions and 109 deletions
+6
View File
@@ -5,5 +5,11 @@ const resource = 'accounts'
export default {
getAll ({ domain, role }) {
return repository.get(`/${resource}/`, { params: { domain, role } })
},
validate (data) {
return repository.post(`${resource}/validate/`, data)
},
create (data) {
return repository.post(`${resource}/`, data)
}
}
@@ -0,0 +1,61 @@
<template>
<validation-observer ref="observer">
<validation-provider
vid="alias"
v-slot="{ errors }"
>
<email-field
ref="aliasField"
v-model="currentAlias"
:placeholder="'Start typing a name here...'|translate"
@domain-selected="addAlias"
:hint="'Alias(es) of this mailbox. To create a catchall alias, just enter the domain name (@domain.tld).'|translate"
persistent-hint
:error-messages="errors"
/>
</validation-provider>
<v-chip
v-for="(alias, index) in account.aliases"
:key="index"
class="mr-2 mt-2"
close
@click:close="account.aliases.splice(index, 1)"
>
{{ alias }}
</v-chip>
</validation-observer>
</template>
<script>
import accounts from '@/api/accounts'
import EmailField from '@/components/tools/EmailField'
export default {
props: ['account'],
components: {
EmailField
},
data () {
return {
currentAlias: ''
}
},
methods: {
async addAlias () {
try {
await accounts.validate({ aliases: [this.currentAlias] })
this.account.aliases.push(this.currentAlias)
this.$refs.aliasField.reset()
} catch (error) {
let errorMsg = null
if (error.response.data.aliases) {
errorMsg = error.response.data.aliases
} else if (error.response.data.non_field_errors) {
errorMsg = error.response.data.non_field_errors
}
this.$refs.observer.setErrors({ alias: errorMsg })
}
}
}
}
</script>
@@ -7,11 +7,19 @@
max-width="190"
/>
<v-stepper-step :complete="step > 1" step="1">
<translate>General</translate>
<translate>Role</translate>
</v-stepper-step>
<v-stepper-step :complete="step > 2" step="2">
<translate>Mail</translate>
<translate>Identification</translate>
</v-stepper-step>
<v-stepper-step :complete="step > 3" step="3">
<translate>Mailbox</translate>
</v-stepper-step>
<v-stepper-step :complete="step > 4" step="4">
<translate>Aliases</translate>
</v-stepper-step>
<v-stepper-step step="5">
<translate>Summary</translate>
</v-stepper-step>
<v-btn icon @click="close">
<v-icon color="primary" x-large>mdi-close</v-icon>
@@ -21,10 +29,10 @@
<v-stepper-content step="1" class="flex-grow-0">
<div class="mb-6 text-h5">
<translate class="grey--text text--darken-1">New account</translate> /
<translate>General</translate>
<translate>Role</translate>
</div>
<account-general-form ref="form_1" :account="account" />
<div class="d-flex justify-end">
<account-role-form ref="form_1" :account="account" />
<div class="d-flex justify-end mt-4">
<v-btn
color="primary"
@click="goToNextStep(1, 2)"
@@ -34,6 +42,79 @@
</v-btn>
</div>
</v-stepper-content>
<v-stepper-content step="2" class="flex-grow-0">
<div class="mb-6 text-h5">
<translate class="grey--text text--darken-1">New account</translate> /
<translate>Identification</translate>
</div>
<account-general-form v-if="step >= 2" ref="form_2" :account="account" />
<div class="d-flex justify-end">
<v-btn @click="step = 1" class="mr-10" text>
<translate>Back</translate>
</v-btn>
<v-btn
color="primary"
@click="goToNextStep(2, 3)"
large
>
<translate>Next</translate>
</v-btn>
</div>
</v-stepper-content>
<v-stepper-content step="3" class="flex-grow-0">
<div class="mb-6 text-h5">
<translate class="grey--text text--darken-1">New account</translate> /
<translate>Mailbox</translate>
</div>
<account-mailbox-form ref="form_3" :account="account" />
<div class="d-flex justify-end">
<v-btn @click="step = 2" class="mr-10" text>
<translate>Back</translate>
</v-btn>
<v-btn
color="primary"
@click="goToNextStep(3, 4)"
large
>
<translate>Next</translate>
</v-btn>
</div>
</v-stepper-content>
<v-stepper-content step="4" class="flex-grow-0">
<div class="mb-6 text-h5">
<translate class="grey--text text--darken-1">New account</translate> /
<translate>Aliases</translate>
</div>
<account-alias-form v-if="step >= 4" ref="form_4" :account="account" />
<div class="d-flex justify-end">
<v-btn @click="step = 3" class="mr-10" text>
<translate>Back</translate>
</v-btn>
<v-btn
color="primary"
@click="goToNextStep(4, 5)"
large
>
<translate>Next</translate>
</v-btn>
</div>
</v-stepper-content>
<v-stepper-content step="5" class="flex-grow-0">
<div class="text-center text-h3"><translate>Summary</translate></div>
<creation-summary
:sections="summarySections"
@modify-step="val => step = val"
/>
<div class="d-flex justify-center mt-8">
<v-btn
color="primary"
@click="submit"
large
>
<translate>Confirm and create</translate>
</v-btn>
</div>
</v-stepper-content>
</v-stepper-items>
</v-stepper>
<confirm-dialog ref="confirm" />
@@ -41,21 +122,98 @@
</template>
<script>
import { bus } from '@/main'
import accounts from '@/api/accounts'
import AccountAliasForm from './AccountAliasForm'
import AccountGeneralForm from './AccountGeneralForm'
import AccountMailboxForm from './AccountMailboxForm'
import AccountRoleForm from './AccountRoleForm'
import ConfirmDialog from '@/components/layout/ConfirmDialog'
import CreationSummary from '@/components/tools/CreationSummary'
export default {
components: {
AccountAliasForm,
AccountGeneralForm,
ConfirmDialog
AccountMailboxForm,
AccountRoleForm,
ConfirmDialog,
CreationSummary
},
computed: {
summarySections () {
const result = [
{
title: this.$gettext('Role'),
items: [
{ key: this.$gettext('Role'), value: this.account.role }
]
},
{
title: this.$gettext('Identification'),
items: [
{ key: this.$gettext('Username'), value: this.account.username },
{ key: this.$gettext('First name'), value: this.account.first_name },
{ key: this.$gettext('Last name'), value: this.account.last_name },
{
key: this.$gettext('Random password'),
value: this.account.random_password,
type: 'yesno'
},
{
key: this.$gettext('Enabled'),
value: this.account.is_active,
type: 'yesno'
}
]
},
{
title: this.$gettext('Mailbox'),
items: [
{ key: this.$gettext('Email'), value: this.account.mailbox.full_address },
{
key: this.$gettext('Quota'),
value: (this.account.mailbox.use_domain_quota)
? this.$gettext('Domain default value')
: this.account.mailbox.quota
},
{
key: this.$gettext('Message sending limit'),
value: this.account.mailbox.message_limit
}
]
},
{
title: this.$gettext('Aliases'),
items: [
{
key: this.$gettext('Aliases'),
value: this.account.aliases,
type: 'list'
}
]
}
]
return result
}
},
data () {
return {
account: {},
account: this.getInitialForm(),
step: 1
}
},
methods: {
getInitialForm () {
return {
aliases: [],
is_active: true,
role: 'SimpleUsers',
mailbox: {
use_domain_quota: true
}
}
},
async close (withConfirm) {
if (withConfirm) {
const confirm = await this.$refs.confirm.open(
@@ -69,6 +227,7 @@ export default {
return
}
}
this.account = this.getInitialForm()
this.$emit('close')
this.step = 1
},
@@ -77,8 +236,24 @@ export default {
if (!valid) {
return
}
try {
await accounts.validate(this.account)
} catch (error) {
this.$refs[`form_${current}`].$refs.observer.setErrors(error.response.data)
return
}
this.step = next
},
submit () {
accounts.create(this.account).then(resp => {
bus.$emit('notification', { msg: this.$gettext('Account created') })
this.$emit('created')
this.close()
})
}
},
mounted () {
this.$store.dispatch('identities/fetchDomains')
}
}
</script>
@@ -3,37 +3,25 @@
<validation-provider
v-slot="{ errors }"
rules="required"
vid="username"
>
<choice-field v-model="account.role"
:label="'Role' | translate"
:choices="accountRoles"
:error-messages="errors"
/>
</validation-provider>
<validation-provider
v-slot="{ errors }"
rules="required"
>
<v-text-field
<email-field
v-model="account.username"
:label="'Username' | translate"
:error-messages="errors"
outlined
class="mt-4"
dense
/>
</validation-provider>
<v-text-field
v-model="account.first_name"
:label="'First name' | translate"
outlined
dense
/>
<v-text-field
v-model="account.last_name"
:label="'Last name' | translate"
outlined
dense
/>
<v-switch
v-model="account.random_password"
@@ -51,7 +39,6 @@
:error-messages="errors"
outlined
type="password"
dense
/>
</validation-provider>
<validation-provider
@@ -65,7 +52,6 @@
:error-messages="errors"
outlined
type="password"
dense
/>
</validation-provider>
<v-switch
@@ -77,34 +63,20 @@
</template>
<script>
import ChoiceField from '@/components/tools/ChoiceField'
import EmailField from '@/components/tools/EmailField'
export default {
components: {
ChoiceField
EmailField
},
props: ['account'],
data () {
return {
accountRoles: [
{
label: this.$gettext('Domain administrator'),
value: 'DomainAdmins'
},
{
label: this.$gettext('Reseller'),
value: 'Resellers'
},
{
label: this.$gettext('Simple user'),
value: 'SimpleUsers'
},
{
label: this.$gettext('Super administrator'),
value: 'SuperAdmins'
}
]
}
},
methods: {
},
mounted () {
}
}
</script>
@@ -0,0 +1,88 @@
<template>
<validation-observer ref="observer">
<validation-provider
v-slot="{ errors }"
rules="required"
>
<v-text-field
v-model="account.mailbox.full_address"
:label="'Email' | translate"
:error-messages="errors"
:disabled="account.role === 'SimpleUsers'"
outlined
/>
</validation-provider>
<div class="d-flex">
<v-text-field
v-model="account.mailbox.quota"
:label="'Quota' | translate"
:hint="'Quota for this mailbox, can be expressed in KB, MB (default) or GB. Define a custom value or use domain\'s default one. Leave empty to define an unlimited value (not allowed for domain administrators).' | translate"
persistent-hint
class="mr-4 quota"
:disabled="account.mailbox.use_domain_quota"
outlined
/>
<v-switch
v-model="account.mailbox.use_domain_quota"
:label="useDomainQuotaLabel"
/>
</div>
<v-text-field
v-model="account.mailbox.message_limit"
:label="'Message sending limit' | translate"
:hint="'Number of messages this mailbox can send per day. Leave empty for no limit.' | translate"
persistent-hint
outlined
/>
</validation-observer>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
props: ['account'],
computed: {
...mapGetters({
getDomainByName: 'identities/getDomainByName'
}),
domainQuota () {
const email = this.account.mailbox.full_address
if (email && email.indexOf('@') !== -1) {
const domain = this.getDomainByName(email.split('@')[1])
if (domain) {
return parseInt(domain.default_mailbox_quota)
}
}
return undefined
},
useDomainQuotaLabel () {
let result = this.$gettext('Use domain default value')
if (this.domainQuota !== undefined) {
if (this.domainQuota === 0) {
result += ` (${this.$gettext('unlimited')})`
} else {
result += ` (${this.domainQuota} MB)`
}
}
return result
}
},
watch: {
account: {
handler (val) {
if (val.role === 'SimpleUsers') {
this.$set(val.mailbox, 'full_address', val.username)
}
},
deep: true
}
}
}
</script>
<style lang="scss" scoped>
.quota {
max-width: 50%;
}
</style>
@@ -0,0 +1,48 @@
<template>
<validation-observer ref="observer">
<validation-provider
v-slot="{ errors }"
rules="required"
>
<choice-field v-model="account.role"
:label="'Role' | translate"
:choices="accountRoles"
:error-messages="errors"
:choices-per-line="2"
/>
</validation-provider>
</validation-observer>
</template>
<script>
import ChoiceField from '@/components/tools/ChoiceField'
export default {
components: {
ChoiceField
},
props: ['account'],
data () {
return {
accountRoles: [
{
label: this.$gettext('Domain administrator'),
value: 'DomainAdmins'
},
{
label: this.$gettext('Reseller'),
value: 'Resellers'
},
{
label: this.$gettext('Simple user'),
value: 'SimpleUsers'
},
{
label: this.$gettext('Super administrator'),
value: 'SuperAdmins'
}
]
}
}
}
</script>
@@ -76,10 +76,15 @@ export default {
selected: []
}
},
methods: {
fetchIdentities () {
identities.getAll().then(resp => {
this.identities = resp.data
})
}
},
mounted () {
identities.getAll().then(resp => {
this.identities = resp.data
})
this.fetchIdentities()
}
}
</script>
+28 -9
View File
@@ -1,14 +1,15 @@
<template>
<div>
<div class="text-subtitle-1 grey--text text--darken-1 mb-4" :class="{ 'label--disabled': disabled }">
<translate>{{ label }}</translate>
<label class="v-label">{{ label }}</label>
</div>
<div class="d-flex">
<div v-for="(choice, index) in choices"
:key="index"
class="choice rounded pa-10 mr-4 text-center"
<div class="d-flex" v-for="(lineChoices, index1) in formatedChoices" :key="index1">
<div v-for="(choice, index2) in lineChoices"
:key="index2"
class="choice rounded pa-10 mr-4 text-center flex-grow-0 mb-4"
:class="{ 'choice--disabled': disabled, 'choice--selected': !disabled && currentChoice === choice.value }"
@click="selectChoice(choice.value)">
@click="selectChoice(choice.value)"
>
<v-icon v-if="choice.icon" class="d-block mb-2" :color="iconColor(choice.value)" x-large>{{ choice.icon }}</v-icon>
<translate class="grey--text text--darken-1">{{ choice.label }}</translate>
</div>
@@ -25,14 +26,28 @@ export default {
disabled: {
type: Boolean,
default: false
}
},
choicesPerLine: Number
},
data () {
return {
currentChoice: null
currentChoice: null,
formatedChoices: []
}
},
methods: {
formatChoices () {
if (this.choicesPerLine) {
let sliceIndex = 0
while (sliceIndex < this.choices.length) {
const result = this.choices.slice(sliceIndex, sliceIndex + this.choicesPerLine)
this.formatedChoices.push(result)
sliceIndex += this.choicesPerLine
}
} else {
this.formatedChoices.push(this.choices)
}
},
iconColor (value) {
return (!this.disabled && value === this.currentChoice) ? 'primary' : ''
},
@@ -44,6 +59,9 @@ export default {
this.$emit('input', value)
}
},
mounted () {
this.formatChoices()
},
watch: {
value: {
handler (val) {
@@ -57,7 +75,8 @@ export default {
<style lang="scss" scoped>
.choice {
width: 200px;
// width: 200px;
flex-basis: 200px;
background-color: #F2F5F7;
border: 1px solid #DBDDDF;
cursor: pointer;
@@ -0,0 +1,45 @@
<template>
<div>
<div v-for="(section, index) in sections" :key="index">
<div class="subtitle mt-4 d-flex">
<span class="text-h6">{{ section.title }}</span>
<a href="#" class="edit-link ml-auto" @click="$emit('modify-step', index + 1)"><translate>Modify</translate></a>
</div>
<v-row v-for="(item, itemIndex) in section.items"
:key="itemIndex"
dense
>
<template v-if="item.value !== undefined">
<v-col><span class="grey--text">{{ item.key }}</span></v-col>
<v-col class="text-right" v-if="item.type === 'yesno'">{{ item.value|yesno }}</v-col>
<v-col class="text-right" v-else-if="item.type === 'list'">
{{ displayList(item.value) }}
</v-col>
<v-col class="text-right" v-else>{{ item.value }}</v-col>
</template>
</v-row>
</div>
</div>
</template>
<script>
export default {
props: ['sections'],
methods: {
displayList (value) {
return (value.length) ? value.join(', ') : this.$gettext('Empty')
}
}
}
</script>
<style scoped lang="scss">
.subtitle {
color: #000;
border-bottom: 1px solid #DBDDDF;
}
.edit-link {
text-decoration: none;
}
</style>
@@ -0,0 +1,161 @@
<template>
<div>
<v-text-field
ref="input"
v-model="input"
outlined
v-bind="$attrs"
v-on="$listeners"
@input="update"
@keydown="onKeyDown"
/>
<div class="menu"
v-if="showMenu"
:style="`width: ${width}px;`"
>
<v-list class="list">
<v-list-item
v-for="(domain, index) in filteredDomains"
:key="index"
@click="selectDomain(domain)"
:class="selectionIndex === index ? 'selected' : ''"
>
<v-list-item-title>{{ domain.name }}</v-list-item-title>
</v-list-item>
</v-list>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
props: ['value'],
computed: {
...mapGetters({
domains: 'identities/domains'
}),
filteredDomains () {
return this.domains.filter(domain => domain.name.startsWith(this.domainSearch))
}
},
data () {
return {
domainSearch: '',
input: this.value,
selectionIndex: 0,
showMenu: false,
width: 500
}
},
methods: {
onKeyDown (e) {
const keyCode = e.keyCode
if (keyCode === 40 || keyCode === 34) {
// on arrow down or page down
if (!this.showMenu) {
this.domainSearch = ''
this.showMenu = true
} else {
this.increaseSelectionIndex()
}
e.preventDefault()
} else if (keyCode === 38 || keyCode === 33) {
// on arrow up or page up
if (!this.showMenu) {
this.domainSearch = ''
this.showMenu = true
} else {
this.decreaseSelectionIndex()
}
e.preventDefault()
} else if (keyCode === 13 || keyCode === 9) {
// on enter or tab
if (this.filteredDomains.length > 0) {
this.selectDomain(this.filteredDomains[this.selectionIndex])
}
e.preventDefault()
} else if (keyCode === 27) {
// on escape
if (this.filteredDomains.length > 0) {
this.showMenu = false
e.preventDefault()
}
}
},
increaseSelectionIndex () {
if (this.selectionIndex >= this.filteredDomains.length - 1) {
this.selectionIndex = 0
} else {
this.selectionIndex += 1
}
},
decreaseSelectionIndex () {
if (this.selectionIndex <= 0) {
this.selectionIndex = this.filteredDomains.length - 1
} else {
this.selectionIndex -= 1
}
},
selectDomain (domain) {
this.input = this.input.split('@')[0] + '@' + domain.name
this.showMenu = false
this.$emit('input', this.input)
this.$emit('domain-selected')
},
update (value) {
if (this.input.indexOf('@') !== -1) {
this.domainSearch = this.input.split('@')[1]
this.showMenu = true
} else {
this.showMenu = false
}
this.$emit('input', this.input)
},
onResize () {
this.updateMinWidthProperty()
},
updateMinWidthProperty () {
this.width = this.$refs.input.$el.clientWidth
},
reset () {
this.input = ''
this.domainSearch = ''
}
},
mounted () {
window.addEventListener('resize', this.onResize)
this.$nextTick(() => {
this.updateMinWidthProperty()
})
}
}
</script>
<style scoped>
.menu {
display: inline-block;
position: fixed;
margin-top: -20px;
padding: 5px 0;
box-shadow: 0 1px 5px 1px rgba(0,0,0,0.3);
overflow-y: auto;
overflow-x: hidden;
contain: content;
z-index: 99;
}
.list {
width: 100%;
height: 100%;
font-size: 1.1em;
overflow-x: hidden;
padding: 0;
}
.selected {
background-color: cornsilk;
}
</style>
+3 -1
View File
@@ -3,13 +3,15 @@ import Vuex from 'vuex'
import auth from '@/store/modules/auth'
import domains from '@/store/modules/domains'
import identities from '@/store/modules/identities'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
auth,
domains
domains,
identities
},
strict: process.env.NODE_ENV !== 'production'
})
+34
View File
@@ -0,0 +1,34 @@
import domainApi from '@/api/domains'
const state = {
domains: []
}
const getters = {
domains: state => state.domains,
getDomainByName: state => name => {
return state.domains.find(domain => domain.name === name)
}
}
const mutations = {
SET_DOMAINS (state, domains) {
state.domains = domains
}
}
const actions = {
fetchDomains ({ commit }) {
domainApi.getDomains().then(resp => {
commit('SET_DOMAINS', resp.data)
})
}
}
export default {
namespaced: true,
state,
getters,
actions,
mutations
}
+6 -2
View File
@@ -26,15 +26,16 @@
</v-list>
</v-menu>
</v-toolbar>
<identity-list />
<identity-list ref="identities" />
<v-dialog
v-model="showCreationWizard"
fullscreen
scrollable
transition="dialog-bottom-transition"
persistent
>
<account-creation-form @close="closeCreationWizard" />
<account-creation-form @close="closeCreationWizard" @created="updateIdentities" />
</v-dialog>
</div>
</template>
@@ -56,6 +57,9 @@ export default {
methods: {
closeCreationWizard () {
this.showCreationWizard = false
},
updateIdentities () {
this.$refs.identities.fetchIdentities()
}
}
}
+23 -27
View File
@@ -153,7 +153,7 @@ class MailboxSerializer(serializers.ModelSerializer):
"""Base mailbox serializer."""
full_address = lib_fields.DRFEmailFieldUTF8()
quota = serializers.CharField()
quota = serializers.CharField(required=False)
class Meta:
model = models.Mailbox
@@ -169,6 +169,15 @@ class MailboxSerializer(serializers.ModelSerializer):
"""Convert quota to MB."""
return web_utils.size2integer(value, output_unit="MB")
def validate(self, data):
"""Check if quota is required."""
if not data.get("use_domain_quota", False):
if "quota" not in data:
raise serializers.ValidationError({
"quota": _("This field is required")
})
return data
class AccountSerializer(serializers.ModelSerializer):
"""Base account serializer."""
@@ -262,7 +271,7 @@ class WritableAccountSerializer(AccountSerializer):
def __init__(self, *args, **kwargs):
"""Adapt fields to current user."""
super(WritableAccountSerializer, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
request = self.context.get("request")
if not request:
return
@@ -292,11 +301,11 @@ class WritableAccountSerializer(AccountSerializer):
"full_address": data["username"],
"use_domain_quota": True
}
elif mailbox["full_address"] != data["username"]:
elif "full_address" in mailbox and mailbox["full_address"] != data["username"]:
raise serializers.ValidationError({
"username": _("Must be equal to mailbox full_address")
})
if not data.get("random_password"):
if not self.partial and not data.get("random_password"):
password = data.get("password")
if password:
try:
@@ -415,45 +424,32 @@ class WritableAccountSerializer(AccountSerializer):
class AliasSerializer(serializers.ModelSerializer):
"""Base Alias serializer."""
"""Alias serializer."""
address = lib_fields.DRFEmailFieldUTF8AndEmptyUser()
recipients = serializers.ListField(
child=lib_fields.DRFEmailFieldUTF8AndEmptyUser(),
allow_empty=False,
help_text=ugettext_lazy("A list of recipient"))
help_text=ugettext_lazy("A list of recipient")
)
class Meta:
model = admin_models.Alias
fields = ("pk", "address", "enabled", "internal", "recipients")
fields = (
"pk", "address", "enabled", "internal", "recipients"
)
def validate_address(self, value):
"""Check domain."""
local_part, domain = email_utils.split_mailbox(value)
self.domain = admin_models.Domain.objects.filter(name=domain).first()
if self.domain is None:
raise serializers.ValidationError(_("Domain not found."))
if not self.context["request"].user.can_access(self.domain):
raise serializers.ValidationError(_("Permission denied."))
local_part, self.domain = admin_models.validate_alias_address(
value, self.context["request"].user)
return value
def create(self, validated_data):
"""Create appropriate objects."""
creator = self.context["request"].user
try:
core_signals.can_create_object.send(
sender=self.__class__, context=creator,
klass=admin_models.Alias)
core_signals.can_create_object.send(
sender=self.__class__, context=self.domain,
object_type="mailbox_aliases")
except lib_exceptions.ModoboaException as inst:
raise serializers.ValidationError(force_text(inst))
recipients = validated_data.pop("recipients", None)
alias = admin_models.Alias(domain=self.domain, **validated_data)
alias.save(creator=creator)
alias.set_recipients(recipients)
return alias
return admin_models.Alias.objects.create(
creator=creator, domain=self.domain, **validated_data)
def update(self, instance, validated_data):
"""Update objects."""
+47
View File
@@ -7,6 +7,7 @@ from rest_framework import serializers
from modoboa.admin.api.v1 import serializers as v1_serializers
from modoboa.core import models as core_models, signals as core_signals
from modoboa.lib import exceptions as lib_exceptions
from modoboa.lib import fields as lib_fields
from modoboa.parameters import tools as param_tools
from ... import constants, lib, models
@@ -148,3 +149,49 @@ class IdentitySerializer(serializers.Serializer):
identity = serializers.CharField()
name_or_rcpt = serializers.CharField()
tags = TagSerializer(many=True)
class WritableAccountSerializer(v1_serializers.WritableAccountSerializer):
"""Add support for aliases and sender addresses."""
aliases = serializers.ListField(
child=lib_fields.DRFEmailFieldUTF8(),
required=False
)
class Meta(v1_serializers.WritableAccountSerializer.Meta):
fields = v1_serializers.WritableAccountSerializer.Meta.fields + (
"aliases",
)
def validate_aliases(self, value):
"""Check if required domains are locals and user can access them."""
aliases = []
for alias in value:
localpart, domain = models.validate_alias_address(
alias, self.context["request"].user)
aliases.append({"localpart": localpart, "domain": domain})
return aliases
def create(self, validated_data):
"""Handle aliases."""
user = self.context["request"].user
aliases = validated_data.pop("aliases", None)
user = super().create(validated_data)
if aliases:
for alias in aliases:
models.Alias.objects.create(
creator=user,
domain=alias.domain,
address=alias.localpart,
recipients=[validated_data["mailbox"]["full_address"]]
)
return user
class AliasSerializer(v1_serializers.AliasSerializer):
"""Alias serializer for v2 API."""
class Meta(v1_serializers.AliasSerializer.Meta):
# We remove 'internal' field
fields = ("pk", "address", "enabled", "internal", "recipients")
+15
View File
@@ -136,6 +136,11 @@ class AccountViewSet(v1_viewsets.AccountViewSet):
filter_backends = (filters.SearchFilter, dj_filters.DjangoFilterBackend)
filterset_class = AccountFilterSet
def get_serializer_class(self):
if self.action in ["create", "validate"]:
return serializers.WritableAccountSerializer
return super().get_serializer_class()
def get_queryset(self):
"""Filter queryset based on current user."""
user = self.request.user
@@ -146,6 +151,16 @@ class AccountViewSet(v1_viewsets.AccountViewSet):
)
return core_models.User.objects.filter(pk__in=ids)
@action(methods=["post"], detail=False)
def validate(self, request, **kwargs):
"""Validate given alias without creating it."""
serializer = self.get_serializer(
data=request.data,
context=self.get_serializer_context(),
partial=True)
serializer.is_valid(raise_exception=True)
return response.Response(status=204)
class IdentityViewSet(viewsets.ViewSet):
"""Viewset for identities."""
+2 -1
View File
@@ -1,7 +1,7 @@
"""Admin models."""
from .alarm import Alarm
from .alias import Alias, AliasRecipient
from .alias import Alias, AliasRecipient, validate_alias_address
from .base import AdminObject
from .domain import Domain
from .domain_alias import DomainAlias
@@ -21,4 +21,5 @@ __all__ = [
"MXRecord",
"Quota",
"SenderAddress",
"validate_alias_address",
]
+47 -22
View File
@@ -3,8 +3,7 @@
import hashlib
import random
from reversion import revisions as reversion
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.encoding import (
@@ -12,10 +11,12 @@ from django.utils.encoding import (
)
from django.utils.translation import ugettext as _, ugettext_lazy
from reversion import revisions as reversion
from modoboa.core import signals as core_signals
from modoboa.lib.email_utils import split_mailbox
from modoboa.lib.exceptions import (
BadRequest, Conflict, NotFound, PermDeniedException
BadRequest, Conflict, NotFound, ModoboaException
)
from .. import signals
from .base import AdminObject
@@ -23,9 +24,43 @@ from .domain import Domain
from .mailbox import Mailbox
@python_2_unicode_compatible
class Alias(AdminObject):
def validate_alias_address(address, creator, internal=False):
"""Check if the given alias address can be created by creator."""
local_part, domain = split_mailbox(address)
domain = Domain.objects.filter(name=domain).first()
if domain is None:
raise ValidationError(_("Domain not found."))
if not creator.can_access(domain):
raise ValidationError(_("Permission denied."))
if Alias.objects.filter(address=address, internal=internal).exists():
raise ValidationError(_("An alias with this name already exists."))
try:
# Check creator limits
core_signals.can_create_object.send(
sender="validate_alias_address", context=creator, klass=Alias)
# Check domain limits
core_signals.can_create_object.send(
sender="validate_alias_address", context=domain,
object_type="mailbox_aliases")
except ModoboaException as inst:
raise ValidationError(str(inst))
return local_part, domain
class AliasManager(models.Manager):
"""Custom manager for Alias."""
def create(self, *args, **kwargs):
"""Custom create method."""
creator = kwargs.pop("creator")
recipients = kwargs.pop("recipients")
alias = super().create(*args, **kwargs)
alias.post_create(creator)
alias.set_recipients(recipients)
return alias
class Alias(AdminObject):
"""Mailbox alias."""
address = models.CharField(
@@ -47,13 +82,15 @@ class Alias(AdminObject):
ugettext_lazy("Expire at"), blank=True, null=True)
_objectname = "MailboxAlias"
objects = AliasManager()
class Meta:
ordering = ["address"]
unique_together = (("address", "internal"), )
app_label = "admin"
def __str__(self):
return smart_text(self.address)
return self.address
@classmethod
def generate_random_address(cls):
@@ -167,23 +204,11 @@ class Alias(AdminObject):
def from_csv(self, user, row, expected_elements=5):
"""Create a new alias from a CSV file entry."""
if len(row) < expected_elements:
raise BadRequest(_("Invalid line: %s" % row))
address = row[1].strip().lower()
localpart, domname = split_mailbox(address)
try:
domain = Domain.objects.get(name=domname)
except Domain.DoesNotExist:
raise BadRequest(_("Domain '%s' does not exist" % domname))
if not user.can_access(domain):
raise PermDeniedException
core_signals.can_create_object.send(
sender="import", context=user, klass=Alias)
core_signals.can_create_object.send(
sender="import", context=domain, object_type="mailbox_aliases")
if Alias.objects.filter(address=address, internal=False).exists():
raise BadRequest(_("Invalid line: {}").format(row))
self.address = row[1].strip().lower()
local_part, self.domain = validate_alias_address(self.address, user)
if Alias.objects.filter(address=self.address, internal=False).exists():
raise Conflict
self.address = address
self.domain = domain
self.enabled = (row[2].strip().lower() in ["true", "1", "yes", "y"])
self.save()
self.set_recipients([raddress.strip() for raddress in row[3:]])