Added mailbox-address cross-validation

This commit is contained in:
Marc Aymerich 2015-10-07 11:44:30 +00:00
parent 5291df3467
commit b4dddef777
13 changed files with 291 additions and 215 deletions

View File

@ -187,8 +187,6 @@ https://code.djangoproject.com/ticket/24576
# FIXME what to do when deleting accounts? set fk null and fill a username charfield? issues, invoices.. we whant all this to go away? # FIXME what to do when deleting accounts? set fk null and fill a username charfield? issues, invoices.. we whant all this to go away?
* implement delete All related services * implement delete All related services
# FIXME address name change does not remove old one :P, readonly or perhaps we can regenerate all addresses using backend.prepare()?
* read https://docs.djangoproject.com/en/dev/releases/1.8/ and fix deprecation warnings * read https://docs.djangoproject.com/en/dev/releases/1.8/ and fix deprecation warnings
* create nice fieldsets for SaaS, WebApp types and services, and helptexts too! * create nice fieldsets for SaaS, WebApp types and services, and helptexts too!
@ -412,7 +410,6 @@ http://makandracards.com/makandra/24933-chrome-34+-firefox-38+-ie11+-ignore-auto
mkhomedir_helper or create ssh homes with bash.rc and such mkhomedir_helper or create ssh homes with bash.rc and such
# warnings if some plugins are disabled, like make routes red # warnings if some plugins are disabled, like make routes red
# replace show emails by https://docs.python.org/3/library/email.contentmanager.html#module-email.contentmanager # replace show emails by https://docs.python.org/3/library/email.contentmanager.html#module-email.contentmanager
# SElect contact list breadcrumbs # put addressform.clean on model.clean and search for other places?

View File

@ -169,6 +169,7 @@ MIDDLEWARE_CLASSES = (
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
# 'django.middleware.locale.LocaleMiddleware'
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',

View File

@ -76,24 +76,22 @@ class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin)
kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4}) kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4})
return super(AccountAdmin, self).formfield_for_dbfield(db_field, **kwargs) return super(AccountAdmin, self).formfield_for_dbfield(db_field, **kwargs)
def change_view(self, request, object_id, form_url='', extra_context=None): def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None):
if request.method == 'GET': if not add:
account = self.get_object(request, unquote(object_id)) if request.method == 'GET' and not obj.is_active:
if not account.is_active:
messages.warning(request, 'This account is disabled.') messages.warning(request, 'This account is disabled.')
context = { context.update({
'services': sorted( 'services': sorted(
[model._meta for model in services.get() if model is not Account], [model._meta for model in services.get() if model is not Account],
key=lambda i: i.verbose_name_plural.lower() key=lambda i: i.verbose_name_plural.lower()
), ),
'accounts': sorted( 'accounts': sorted(
[model._meta for model in accounts.get() if model is not Account], [model._meta for model in accounts.get() if model is not Account],
key=lambda i: i.verbose_name_plural.lower() key=lambda i: i.verbose_name_plural.lower()
) )
} })
context.update(extra_context or {}) return super(AccountAdmin, self).render_change_form(
return super(AccountAdmin, self).change_view( request, context, add, change, form_url, obj)
request, object_id, form_url=form_url, extra_context=context)
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
fieldsets = super(AccountAdmin, self).get_fieldsets(request, obj) fieldsets = super(AccountAdmin, self).get_fieldsets(request, obj)

View File

@ -82,19 +82,16 @@ class LogEntryAdmin(admin.ModelAdmin):
content_object_link.admin_order_field = 'object_repr' content_object_link.admin_order_field = 'object_repr'
content_object_link.allow_tags = True content_object_link.allow_tags = True
def changeform_view(self, request, object_id=None, form_url='', extra_context=None): def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None):
""" Add rel_opts and object to context """ """ Add rel_opts and object to context """
context = {} if not add and 'edit' in request.GET.urlencode():
if 'edit' in request.GET.urlencode(): context.update({
obj = self.get_object(request, unquote(object_id))
context = {
'rel_opts': obj.content_type.model_class()._meta, 'rel_opts': obj.content_type.model_class()._meta,
'object': obj, 'object': obj,
} })
context.update(extra_context or {}) return super(LogEntryAdmin, self).render_change_form(
return super(LogEntryAdmin, self).changeform_view( request, context, add, change, form_url, obj)
request, object_id, form_url, extra_context=context)
def response_change(self, request, obj): def response_change(self, request, obj):
""" save and continue preserve edit query string """ """ save and continue preserve edit query string """
response = super(LogEntryAdmin, self).response_change(request, obj) response = super(LogEntryAdmin, self).response_change(request, obj)

View File

@ -2,9 +2,11 @@ import copy
from urllib.parse import parse_qs from urllib.parse import parse_qs
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin, messages
from django.core.urlresolvers import reverse
from django.db.models import F, Value as V from django.db.models import F, Value as V
from django.db.models.functions import Concat from django.db.models.functions import Concat
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin
@ -122,8 +124,27 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo
search_term = search_term.replace('@', ' ') search_term = search_term.replace('@', ' ')
return super(MailboxAdmin, self).get_search_results(request, queryset, search_term) return super(MailboxAdmin, self).get_search_results(request, queryset, search_term)
def render_change_form(self, request, context, *args, **kwargs):
# Check if there exists an unrelated local Address for this mbox
local_domain = settings.MAILBOXES_LOCAL_DOMAIN
obj = kwargs['obj']
if local_domain and obj.name:
non_mbox_addresses = Address.objects.exclude(mailboxes__name=obj.name).exclude(
forward__regex=r'.*(^|\s)+%s($|\s)+.*' % obj.name)
try:
addr = non_mbox_addresses.get(name=obj.name, domain__name=local_domain)
except Address.DoesNotExist:
pass
else:
url = reverse('admin:mailboxes_address_change', args=(addr.pk,))
msg = _("Address <a href='{url}'>{addr}</a> clashes with this mailbox "
"local address. Consider adding this mailbox to the address.").format(
url=url, addr=addr)
self.message_user(request, mark_safe(msg), level=messages.WARNING)
return super(MailboxAdmin, self).render_change_form(request, context, *args, **kwargs)
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
""" save hacky mailbox.addresses """ """ save hacky mailbox.addresses and local domain clashing """
super(MailboxAdmin, self).save_model(request, obj, form, change) super(MailboxAdmin, self).save_model(request, obj, form, change)
obj.addresses = form.cleaned_data['addresses'] obj.addresses = form.cleaned_data['addresses']

View File

@ -11,3 +11,4 @@ class MailboxesConfig(AppConfig):
from .models import Mailbox, Address from .models import Mailbox, Address
services.register(Mailbox, icon='email.png') services.register(Mailbox, icon='email.png')
services.register(Address, icon='X-office-address-book.png') services.register(Address, icon='X-office-address-book.png')
from . import signals

View File

@ -10,7 +10,7 @@ from orchestra.contrib.orchestration import ServiceController
from orchestra.contrib.resources import ServiceMonitor from orchestra.contrib.resources import ServiceMonitor
from . import settings from . import settings
from .models import Address from .models import Address, Mailbox
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -137,97 +137,97 @@ class UNIXUserMaildirBackend(SieveFilteringMixin, ServiceController):
return context return context
class DovecotPostfixPasswdVirtualUserBackend(SieveFilteringMixin, ServiceController): #class DovecotPostfixPasswdVirtualUserBackend(SieveFilteringMixin, ServiceController):
""" # """
WARNING: This backends is not fully implemented # WARNING: This backends is not fully implemented
""" # """
DEFAULT_GROUP = 'postfix' # DEFAULT_GROUP = 'postfix'
#
verbose_name = _("Dovecot-Postfix virtualuser") # verbose_name = _("Dovecot-Postfix virtualuser")
model = 'mailboxes.Mailbox' # model = 'mailboxes.Mailbox'
#
def set_user(self, context): # def set_user(self, context):
self.append(textwrap.dedent(""" # self.append(textwrap.dedent("""
if grep '^%(user)s:' %(passwd_path)s > /dev/null ; then # if grep '^%(user)s:' %(passwd_path)s > /dev/null ; then
sed -i 's#^%(user)s:.*#%(passwd)s#' %(passwd_path)s # sed -i 's#^%(user)s:.*#%(passwd)s#' %(passwd_path)s
else # else
echo '%(passwd)s' >> %(passwd_path)s # echo '%(passwd)s' >> %(passwd_path)s
fi""") % context # fi""") % context
) # )
self.append("mkdir -p %(home)s" % context) # self.append("mkdir -p %(home)s" % context)
self.append("chown %(uid)s:%(gid)s %(home)s" % context) # self.append("chown %(uid)s:%(gid)s %(home)s" % context)
#
def set_mailbox(self, context): # def set_mailbox(self, context):
self.append(textwrap.dedent(""" # self.append(textwrap.dedent("""
if ! grep '^%(user)s@%(mailbox_domain)s\s' %(virtual_mailbox_maps)s > /dev/null; then # if ! grep '^%(user)s@%(mailbox_domain)s\s' %(virtual_mailbox_maps)s > /dev/null; then
echo "%(user)s@%(mailbox_domain)s\tOK" >> %(virtual_mailbox_maps)s # echo "%(user)s@%(mailbox_domain)s\tOK" >> %(virtual_mailbox_maps)s
UPDATED_VIRTUAL_MAILBOX_MAPS=1 # UPDATED_VIRTUAL_MAILBOX_MAPS=1
fi""") % context # fi""") % context
) # )
#
def save(self, mailbox): # def save(self, mailbox):
context = self.get_context(mailbox) # context = self.get_context(mailbox)
self.set_user(context) # self.set_user(context)
self.set_mailbox(context) # self.set_mailbox(context)
self.generate_filter(mailbox, context) # self.generate_filter(mailbox, context)
#
def delete(self, mailbox): # def delete(self, mailbox):
context = self.get_context(mailbox) # context = self.get_context(mailbox)
self.append(textwrap.dedent(""" # self.append(textwrap.dedent("""
nohup bash -c 'sleep 2 && killall -u %(uid)s -s KILL' &> /dev/null & # nohup bash -c 'sleep 2 && killall -u %(uid)s -s KILL' &> /dev/null &
killall -u %(uid)s || true # killall -u %(uid)s || true
sed -i '/^%(user)s:.*/d' %(passwd_path)s # sed -i '/^%(user)s:.*/d' %(passwd_path)s
sed -i '/^%(user)s@%(mailbox_domain)s\s.*/d' %(virtual_mailbox_maps)s # sed -i '/^%(user)s@%(mailbox_domain)s\s.*/d' %(virtual_mailbox_maps)s
UPDATED_VIRTUAL_MAILBOX_MAPS=1""") % context # UPDATED_VIRTUAL_MAILBOX_MAPS=1""") % context
) # )
if context['deleted_home']: # if context['deleted_home']:
self.append("mv %(home)s %(deleted_home)s || exit_code=$?" % context) # self.append("mv %(home)s %(deleted_home)s || exit_code=$?" % context)
else: # else:
self.append("rm -fr %(home)s" % context) # self.append("rm -fr %(home)s" % context)
#
def get_extra_fields(self, mailbox, context): # def get_extra_fields(self, mailbox, context):
context['quota'] = self.get_quota(mailbox) # context['quota'] = self.get_quota(mailbox)
return 'userdb_mail=maildir:~/Maildir {quota}'.format(**context) # return 'userdb_mail=maildir:~/Maildir {quota}'.format(**context)
#
def get_quota(self, mailbox): # def get_quota(self, mailbox):
try: # try:
quota = mailbox.resources.disk.allocated # quota = mailbox.resources.disk.allocated
except (AttributeError, ObjectDoesNotExist): # except (AttributeError, ObjectDoesNotExist):
return '' # return ''
unit = mailbox.resources.disk.unit[0].upper() # unit = mailbox.resources.disk.unit[0].upper()
return 'userdb_quota_rule=*:bytes=%i%s' % (quota, unit) # return 'userdb_quota_rule=*:bytes=%i%s' % (quota, unit)
#
def commit(self): # def commit(self):
context = { # context = {
'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH # 'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH
} # }
self.append(textwrap.dedent(""" # self.append(textwrap.dedent("""
[[ $UPDATED_VIRTUAL_MAILBOX_MAPS == 1 ]] && { # [[ $UPDATED_VIRTUAL_MAILBOX_MAPS == 1 ]] && {
postmap %(virtual_mailbox_maps)s # postmap %(virtual_mailbox_maps)s
}""") % context # }""") % context
) # )
#
def get_context(self, mailbox): # def get_context(self, mailbox):
context = { # context = {
'name': mailbox.name, # 'name': mailbox.name,
'user': mailbox.name, # 'user': mailbox.name,
'password': mailbox.password if mailbox.active else '*%s' % mailbox.password, # 'password': mailbox.password if mailbox.active else '*%s' % mailbox.password,
'uid': 10000 + mailbox.pk, # 'uid': 10000 + mailbox.pk,
'gid': 10000 + mailbox.pk, # 'gid': 10000 + mailbox.pk,
'group': self.DEFAULT_GROUP, # 'group': self.DEFAULT_GROUP,
'quota': self.get_quota(mailbox), # 'quota': self.get_quota(mailbox),
'passwd_path': settings.MAILBOXES_PASSWD_PATH, # 'passwd_path': settings.MAILBOXES_PASSWD_PATH,
'home': mailbox.get_home(), # 'home': mailbox.get_home(),
'banner': self.get_banner(), # 'banner': self.get_banner(),
'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH, # 'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH,
'mailbox_domain': settings.MAILBOXES_VIRTUAL_MAILBOX_DEFAULT_DOMAIN, # 'mailbox_domain': settings.MAILBOXES_VIRTUAL_MAILBOX_DEFAULT_DOMAIN,
} # }
context['extra_fields'] = self.get_extra_fields(mailbox, context) # context['extra_fields'] = self.get_extra_fields(mailbox, context)
context.update({ # context.update({
'passwd': '{user}:{password}:{uid}:{gid}::{home}::{extra_fields}'.format(**context), # 'passwd': '{user}:{password}:{uid}:{gid}::{home}::{extra_fields}'.format(**context),
'deleted_home': settings.MAILBOXES_MOVE_ON_DELETE_PATH % context, # 'deleted_home': settings.MAILBOXES_MOVE_ON_DELETE_PATH % context,
}) # })
return context # return context
class PostfixAddressVirtualDomainBackend(ServiceController): class PostfixAddressVirtualDomainBackend(ServiceController):
@ -301,6 +301,7 @@ class PostfixAddressVirtualDomainBackend(ServiceController):
def get_context(self, address): def get_context(self, address):
context = self.get_context_files() context = self.get_context_files()
context.update({ context.update({
'name': address.name,
'domain': address.domain, 'domain': address.domain,
'email': address.email, 'email': address.email,
'local_domain': settings.MAILBOXES_LOCAL_DOMAIN, 'local_domain': settings.MAILBOXES_LOCAL_DOMAIN,
@ -319,10 +320,19 @@ class PostfixAddressBackend(PostfixAddressVirtualDomainBackend):
'MAILBOXES_VIRTUAL_ALIAS_MAPS_PATH' 'MAILBOXES_VIRTUAL_ALIAS_MAPS_PATH'
)) ))
def is_implicit_entry(self, context):
"""
check if virtual_alias_map entry can be omitted because the address is
equivalent to its local mbox
"""
return bool(
context['domain'].name == context['local_domain'] and
context['destination'] == context['name'] and
Mailbox.objects.filter(name=context['name']).exists())
def update_virtual_alias_maps(self, address, context): def update_virtual_alias_maps(self, address, context):
destination = address.destination context['destination'] = address.destination
if destination: if not self.is_implicit_entry(context):
context['destination'] = destination
self.append(textwrap.dedent(""" self.append(textwrap.dedent("""
# Set virtual alias entry for %(email)s # Set virtual alias entry for %(email)s
LINE='%(email)s\t%(destination)s' LINE='%(email)s\t%(destination)s'
@ -338,7 +348,12 @@ class PostfixAddressBackend(PostfixAddressVirtualDomainBackend):
fi fi
fi""") % context) fi""") % context)
else: else:
logger.warning("Address %i is empty" % address.pk) if not context['destination']:
msg = "Address %i is empty" % address.pk
self.append("\necho 'msg' >&2" % msg)
logger.warning(msg)
else:
self.append("\n# %(email)s %(destination)s entry is redundant" % context)
self.exclude_virtual_alias_maps(context) self.exclude_virtual_alias_maps(context)
# Virtual mailbox stuff # Virtual mailbox stuff
# destination = [] # destination = []
@ -350,7 +365,7 @@ class PostfixAddressBackend(PostfixAddressVirtualDomainBackend):
# destination.append(forward) # destination.append(forward)
def exclude_virtual_alias_maps(self, context): def exclude_virtual_alias_maps(self, context):
self.append(textwrap.dedent(""" self.append(textwrap.dedent("""\
# Remove %(email)s virtual alias entry # Remove %(email)s virtual alias entry
if grep '^%(email)s\s' %(virtual_alias_maps)s > /dev/null; then if grep '^%(email)s\s' %(virtual_alias_maps)s > /dev/null; then
sed -i '/^%(email)s\s/d' %(virtual_alias_maps)s sed -i '/^%(email)s\s/d' %(virtual_alias_maps)s

View File

@ -1,12 +1,14 @@
from django import forms from django import forms
from django.contrib.admin import widgets from django.contrib.admin import widgets
from django.core.exceptions import ValidationError
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from orchestra.forms import UserCreationForm, UserChangeForm from orchestra.forms import UserCreationForm, UserChangeForm
from orchestra.utils.python import AttrDict from orchestra.utils.python import AttrDict
from .models import Address from . import settings
from .models import Address, Mailbox
class MailboxForm(forms.ModelForm): class MailboxForm(forms.ModelForm):
@ -37,21 +39,28 @@ class MailboxForm(forms.ModelForm):
return mark_safe(output) return mark_safe(output)
self.fields['addresses'].widget.render = render self.fields['addresses'].widget.render = render
queryset = self.fields['addresses'].queryset queryset = self.fields['addresses'].queryset
realted_addresses = queryset.filter(account=self.modeladmin.account.pk).order_by('name') realted_addresses = queryset.filter(account_id=self.modeladmin.account.pk).order_by('name')
self.fields['addresses'].queryset = realted_addresses self.fields['addresses'].queryset = realted_addresses
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
self.fields['addresses'].initial = self.instance.addresses.all() self.fields['addresses'].initial = self.instance.addresses.all()
def clean_custom_filtering(self): def clean(self):
# TODO move to model.clean? cleaned_data = super(MailboxForm, self).clean()
filtering = self.cleaned_data['filtering'] name = self.instance.name if self.instance.pk else cleaned_data.get('name')
custom_filtering = self.cleaned_data['custom_filtering'] local_domain = settings.MAILBOXES_LOCAL_DOMAIN
if filtering == self._meta.model.CUSTOM and not custom_filtering: if name and local_domain:
raise forms.ValidationError({ try:
'custom_filtering': _("You didn't provide any custom filtering.") addr = Address.objects.get(name=name, domain__name=local_domain, account_id=self.modeladmin.account.pk)
}) except Address.DoesNotExist:
return custom_filtering pass
else:
if addr not in cleaned_data.get('addresses', []):
raise ValidationError({
'addresses': _("This mailbox matches local address '%s', "
"please make explicit this fact by selecting it.") % addr
})
return cleaned_data
class MailboxChangeForm(UserChangeForm, MailboxForm): class MailboxChangeForm(UserChangeForm, MailboxForm):
@ -73,5 +82,23 @@ class MailboxCreationForm(UserCreationForm, MailboxForm):
class AddressForm(forms.ModelForm): class AddressForm(forms.ModelForm):
def clean(self): def clean(self):
cleaned_data = super(AddressForm, self).clean() cleaned_data = super(AddressForm, self).clean()
if not cleaned_data.get('mailboxes', True) and not cleaned_data['forward']: forward = cleaned_data.get('forward', '')
raise forms.ValidationError(_("Mailboxes or forward address should be provided.")) if not cleaned_data.get('mailboxes', True) and not forward:
raise ValidationError(_("Mailboxes or forward address should be provided."))
# Check if new addresse matches with a mbox because of having a local domain
if self.instance.pk:
name = self.instance.name
domain = self.instance.domain
else:
name = cleaned_data.get('name')
domain = cleaned_data.get('domain')
if domain and name and domain.name == settings.MAILBOXES_LOCAL_DOMAIN:
if name not in forward.split() and Mailbox.objects.filter(name=name).exists():
for mailbox in cleaned_data.get('mailboxes', []):
if mailbox.name == name:
return
raise ValidationError(
_("This address matches mailbox '%s', please make explicit this fact "
"by adding the mailbox on the mailboxes or forward field.") % name
)
return cleaned_data

View File

@ -1,4 +1,5 @@
import os import os
from collections import defaultdict
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.core.validators import RegexValidator, ValidationError from django.core.validators import RegexValidator, ValidationError
@ -59,6 +60,10 @@ class Mailbox(models.Model):
def clean(self): def clean(self):
if self.custom_filtering and self.filtering != self.CUSTOM: if self.custom_filtering and self.filtering != self.CUSTOM:
self.custom_filtering = '' self.custom_filtering = ''
elif self.filtering == self.CUSTOM and not self.custom_filtering:
raise ValidationError({
'custom_filtering': _("Custom filtering is selected but not provided.")
})
def get_filtering(self): def get_filtering(self):
name, content = settings.MAILBOXES_MAILBOX_FILTERINGS[self.filtering] name, content = settings.MAILBOXES_MAILBOX_FILTERINGS[self.filtering]
@ -66,18 +71,6 @@ class Mailbox(models.Model):
content = content(self) content = content(self)
return (name, content) return (name, content)
def delete(self, *args, **kwargs):
super(Mailbox, self).delete(*args, **kwargs)
# Cleanup related addresses
for address in Address.objects.filter(forward__regex=r'.*(^|\s)+%s($|\s)+.*' % self.name):
forward = address.forward.split()
forward.remove(self.name)
address.forward = ' '.join(forward)
if not address.destination:
address.delete()
else:
address.save()
def get_local_address(self): def get_local_address(self):
if not settings.MAILBOXES_LOCAL_ADDRESS_DOMAIN: if not settings.MAILBOXES_LOCAL_ADDRESS_DOMAIN:
raise AttributeError("Mailboxes do not have a defined local address domain.") raise AttributeError("Mailboxes do not have a defined local address domain.")
@ -117,17 +110,30 @@ class Address(models.Model):
return ' '.join(destinations) return ' '.join(destinations)
def clean(self): def clean(self):
errors = defaultdict(list)
local_domain = settings.MAILBOXES_LOCAL_DOMAIN
if local_domain:
forwards = self.forward.split()
for ix, forward in enumerate(forwards):
if forward.endswith('@%s' % local_domain):
name = forward.split('@')[0]
if Mailbox.objects.filter(name=name).exists():
forwards[ix] = name
self.forward = ' '.join(forwards)
if self.account_id: if self.account_id:
forward_errors = []
for mailbox in self.get_forward_mailboxes(): for mailbox in self.get_forward_mailboxes():
if mailbox.account_id == self.account_id: if mailbox.account_id == self.account_id:
forward_errors.append(ValidationError( errors['forward'].append(
_("Please use mailboxes field for '%s' mailbox.") % mailbox _("Please use mailboxes field for '%s' mailbox.") % mailbox
)) )
if forward_errors: if self.domain:
raise ValidationError({ for forward in self.forward.split():
'forward': forward_errors if self.email == forward:
}) errors['forward'].append(
_("'%s' forwards to itself.") % forward
)
if errors:
raise ValidationError(errors)
def get_forward_mailboxes(self): def get_forward_mailboxes(self):
for forward in self.forward.split(): for forward in self.forward.split():

View File

@ -0,0 +1,43 @@
from django.db.models.signals import pre_save, post_delete
from django.dispatch import receiver
from . import settings
from .models import Mailbox, Address
# Admin bulk deletion doesn't call model.delete()
# So, signals are used instead of model method overriding
@receiver(post_delete, sender=Mailbox, dispatch_uid='mailboxes.delete_forwards')
def delete_forwards(sender, *args, **kwargs):
# Cleanup related addresses
instance = kwargs['instance']
for address in Address.objects.filter(forward__regex=r'.*(^|\s)+%s($|\s)+.*' % instance.name):
forward = address.forward.split()
forward.remove(instance.name)
address.forward = ' '.join(forward)
if not address.destination:
address.delete()
else:
address.save()
@receiver(pre_save, sender=Mailbox, dispatch_uid='mailboxes.create_local_address')
def create_local_address(sender, *args, **kwargs):
mbox = kwargs['instance']
local_domain = settings.MAILBOXES_LOCAL_DOMAIN
if not mbox.pk and local_domain:
Domain = Address._meta.get_field_by_name('domain')[0].rel.to
try:
domain = Domain.objects.get(name=local_domain)
except Domain.DoesNotExist:
pass
else:
addr, created = Address.objects.get_or_create(
name=mbox.name, domain=domain, account_id=domain.account_id)
if created:
if domain.account_id == mbox.account_id:
addr.mailboxes.add(mbox)
else:
addr.forward = mbox.name
addr.save(update_fields=('forward',))

View File

@ -94,7 +94,8 @@ class RouteAdmin(ExtendedModelAdmin):
def changeform_view(self, request, object_id=None, form_url='', extra_context=None): def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
self.show_orchestration_disabled(request) self.show_orchestration_disabled(request)
return super(RouteAdmin, self).changeform_view(request, object_id, form_url, extra_context) return super(RouteAdmin, self).changeform_view(
request, object_id, form_url, extra_context)
class BackendOperationInline(admin.TabularInline): class BackendOperationInline(admin.TabularInline):

View File

@ -28,11 +28,11 @@ class WebAppServiceMixin(object):
if context['under_construction_path']: if context['under_construction_path']:
self.append(textwrap.dedent(""" self.append(textwrap.dedent("""
# Set under construction if needed # Set under construction if needed
if [[ $CREATED == 1 && ! $(ls -A %(app_path)s) ]]; then if [[ $CREATED == 1 && ! $(ls -A %(app_path)s | head -n1) ]]; then
# Async wait 2 more seconds for other backends to lock app_path or cp under construction # Async wait some seconds for other backends to lock app_path or cp under construction
nohup bash -c ' nohup bash -c '
sleep 2 sleep 2
if [[ ! $(ls -A %(app_path)s) ]]; then if [[ ! $(ls -A %(app_path)s | head -n1) ]]; then
cp -r %(under_construction_path)s %(app_path)s cp -r %(under_construction_path)s %(app_path)s
chown -R %(user)s:%(group)s %(app_path)s chown -R %(user)s:%(group)s %(app_path)s
fi' &> /dev/null & fi' &> /dev/null &

View File

@ -4,34 +4,19 @@ from django.utils import timezone
from django.utils.translation import ungettext, ugettext as _ from django.utils.translation import ungettext, ugettext as _
def pluralize_year(n): def verbose_time(n, units):
if n >= 5:
return _("{n} {units} ago").format(n=int(n), units=units)
return ungettext( return ungettext(
_('{num:.1f} year{ago}'), _("{n:.1f} {s_units} ago"),
_('{num:.1f} years{ago}'), n) _("{n:.1f} {units} ago"), n
).format(n=n, units=units, s_units=units[:-1])
def pluralize_month(n):
return ungettext(
_('{num:.1f} month{ago}'),
_('{num:.1f} months{ago}'), n)
def pluralize_week(n):
return ungettext(
_('{num:.1f} week{ago}'),
_('{num:.1f} weeks {ago}'), n)
def pluralize_day(n):
return ungettext(
_('{num:.1f} day{ago}'),
_('{num:.1f} days{ago}'), n)
OLDER_CHUNKS = ( OLDER_CHUNKS = (
(365.0, pluralize_year), (365.0, 'years'),
(30.0, pluralize_month), (30.0, 'months'),
(7.0, pluralize_week), (7.0, 'weeks'),
) )
@ -52,50 +37,34 @@ def naturaldatetime(date, show_seconds=False):
delta_midnight = today - date delta_midnight = today - date
days = delta.days days = delta.days
hours = int(round(delta.seconds / 3600, 0)) hours = float(delta.seconds) / 3600
minutes = delta.seconds / 60 minutes = float(delta.seconds) / 60
seconds = delta.seconds seconds = delta.seconds
ago = " ago"
if days < 0:
ago = ""
days = abs(days) days = abs(days)
if days == 0: if days == 0:
if hours == 0: if int(hours) == 0:
if minutes >= 1 or not show_seconds: if minutes >= 1 or not show_seconds:
minutes = float(seconds)/60 return verbose_time(minutes, 'minutes')
return ungettext(
_("{minutes:.1f} minute{ago}"),
_("{minutes:.1f} minutes{ago}"), minutes
).format(minutes=minutes, ago=ago)
else: else:
return ungettext( return verbose_time(seconds, 'seconds')
_("{seconds} second{ago}"),
_("{seconds} seconds{ago}"), seconds
).format(seconds=seconds, ago=ago)
else: else:
hours = float(minutes)/60 return verbose_time(hours, 'hours')
return ungettext(
_("{hours:.1f} hour{ago}"),
_("{hours:.1f} hours{ago}"), hours
).format(hours=hours, ago=ago)
if delta_midnight.days == 0: if delta_midnight.days == 0:
date = timezone.localtime(date) date = timezone.localtime(date)
return _("yesterday at {time}").format(time=date.strftime('%H:%M')) return _("yesterday at {time}").format(time=date.strftime('%H:%M'))
count = 0 count = 0
for chunk, pluralizefun in OLDER_CHUNKS: for chunk, units in OLDER_CHUNKS:
if days < 7.0: if days < 7.0:
count = days + float(hours)/24 count = days + float(hours)/24
fmt = pluralize_day(count) return verbose_time(count, 'days')
return fmt.format(num=count, ago=ago)
if days >= chunk: if days >= chunk:
count = (delta_midnight.days + 1) / chunk count = (delta_midnight.days + 1) / chunk
count = abs(count) count = abs(count)
fmt = pluralizefun(count) return verbose_time(count, units)
return fmt.format(num=count, ago=ago)
def naturaldate(date): def naturaldate(date):