From b4dddef777d213188f01dbcd1d33a40adc6e5109 Mon Sep 17 00:00:00 2001 From: Marc Aymerich Date: Wed, 7 Oct 2015 11:44:30 +0000 Subject: [PATCH] Added mailbox-address cross-validation --- TODO.md | 5 +- .../project_template/project_name/settings.py | 1 + orchestra/contrib/accounts/admin.py | 32 ++- orchestra/contrib/history/admin.py | 17 +- orchestra/contrib/mailboxes/admin.py | 25 ++- orchestra/contrib/mailboxes/apps.py | 1 + orchestra/contrib/mailboxes/backends.py | 209 ++++++++++-------- orchestra/contrib/mailboxes/forms.py | 53 +++-- orchestra/contrib/mailboxes/models.py | 44 ++-- orchestra/contrib/mailboxes/signals.py | 43 ++++ orchestra/contrib/orchestration/admin.py | 3 +- .../contrib/webapps/backends/__init__.py | 6 +- orchestra/utils/humanize.py | 67 ++---- 13 files changed, 291 insertions(+), 215 deletions(-) create mode 100644 orchestra/contrib/mailboxes/signals.py diff --git a/TODO.md b/TODO.md index 58888824..4a0c3ab5 100644 --- a/TODO.md +++ b/TODO.md @@ -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? * 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 * 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 # 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 -# SElect contact list breadcrumbs +# put addressform.clean on model.clean and search for other places? diff --git a/orchestra/conf/project_template/project_name/settings.py b/orchestra/conf/project_template/project_name/settings.py index 0c5c9e6f..0ff570b4 100644 --- a/orchestra/conf/project_template/project_name/settings.py +++ b/orchestra/conf/project_template/project_name/settings.py @@ -169,6 +169,7 @@ MIDDLEWARE_CLASSES = ( 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + # 'django.middleware.locale.LocaleMiddleware' 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', diff --git a/orchestra/contrib/accounts/admin.py b/orchestra/contrib/accounts/admin.py index db7e1660..a5dad576 100644 --- a/orchestra/contrib/accounts/admin.py +++ b/orchestra/contrib/accounts/admin.py @@ -76,24 +76,22 @@ class AccountAdmin(ChangePasswordAdminMixin, auth.UserAdmin, ExtendedModelAdmin) kwargs['widget'] = forms.Textarea(attrs={'cols': 70, 'rows': 4}) return super(AccountAdmin, self).formfield_for_dbfield(db_field, **kwargs) - def change_view(self, request, object_id, form_url='', extra_context=None): - if request.method == 'GET': - account = self.get_object(request, unquote(object_id)) - if not account.is_active: + def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): + if not add: + if request.method == 'GET' and not obj.is_active: messages.warning(request, 'This account is disabled.') - context = { - 'services': sorted( - [model._meta for model in services.get() if model is not Account], - key=lambda i: i.verbose_name_plural.lower() - ), - 'accounts': sorted( - [model._meta for model in accounts.get() if model is not Account], - key=lambda i: i.verbose_name_plural.lower() - ) - } - context.update(extra_context or {}) - return super(AccountAdmin, self).change_view( - request, object_id, form_url=form_url, extra_context=context) + context.update({ + 'services': sorted( + [model._meta for model in services.get() if model is not Account], + key=lambda i: i.verbose_name_plural.lower() + ), + 'accounts': sorted( + [model._meta for model in accounts.get() if model is not Account], + key=lambda i: i.verbose_name_plural.lower() + ) + }) + return super(AccountAdmin, self).render_change_form( + request, context, add, change, form_url, obj) def get_fieldsets(self, request, obj=None): fieldsets = super(AccountAdmin, self).get_fieldsets(request, obj) diff --git a/orchestra/contrib/history/admin.py b/orchestra/contrib/history/admin.py index be1d7b5d..efb3b8a0 100644 --- a/orchestra/contrib/history/admin.py +++ b/orchestra/contrib/history/admin.py @@ -82,19 +82,16 @@ class LogEntryAdmin(admin.ModelAdmin): content_object_link.admin_order_field = 'object_repr' 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 """ - context = {} - if 'edit' in request.GET.urlencode(): - obj = self.get_object(request, unquote(object_id)) - context = { + if not add and 'edit' in request.GET.urlencode(): + context.update({ 'rel_opts': obj.content_type.model_class()._meta, 'object': obj, - } - context.update(extra_context or {}) - return super(LogEntryAdmin, self).changeform_view( - request, object_id, form_url, extra_context=context) - + }) + return super(LogEntryAdmin, self).render_change_form( + request, context, add, change, form_url, obj) + def response_change(self, request, obj): """ save and continue preserve edit query string """ response = super(LogEntryAdmin, self).response_change(request, obj) diff --git a/orchestra/contrib/mailboxes/admin.py b/orchestra/contrib/mailboxes/admin.py index 4398945d..9f764869 100644 --- a/orchestra/contrib/mailboxes/admin.py +++ b/orchestra/contrib/mailboxes/admin.py @@ -2,9 +2,11 @@ import copy from urllib.parse import parse_qs 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.functions import Concat +from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ExtendedModelAdmin, ChangePasswordAdminMixin @@ -122,8 +124,27 @@ class MailboxAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedMo search_term = search_term.replace('@', ' ') 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 {addr} 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): - """ save hacky mailbox.addresses """ + """ save hacky mailbox.addresses and local domain clashing """ super(MailboxAdmin, self).save_model(request, obj, form, change) obj.addresses = form.cleaned_data['addresses'] diff --git a/orchestra/contrib/mailboxes/apps.py b/orchestra/contrib/mailboxes/apps.py index 9171c4ea..395cf1e9 100644 --- a/orchestra/contrib/mailboxes/apps.py +++ b/orchestra/contrib/mailboxes/apps.py @@ -11,3 +11,4 @@ class MailboxesConfig(AppConfig): from .models import Mailbox, Address services.register(Mailbox, icon='email.png') services.register(Address, icon='X-office-address-book.png') + from . import signals diff --git a/orchestra/contrib/mailboxes/backends.py b/orchestra/contrib/mailboxes/backends.py index 232997bb..68247d3f 100644 --- a/orchestra/contrib/mailboxes/backends.py +++ b/orchestra/contrib/mailboxes/backends.py @@ -10,7 +10,7 @@ from orchestra.contrib.orchestration import ServiceController from orchestra.contrib.resources import ServiceMonitor from . import settings -from .models import Address +from .models import Address, Mailbox logger = logging.getLogger(__name__) @@ -137,97 +137,97 @@ class UNIXUserMaildirBackend(SieveFilteringMixin, ServiceController): return context -class DovecotPostfixPasswdVirtualUserBackend(SieveFilteringMixin, ServiceController): - """ - WARNING: This backends is not fully implemented - """ - DEFAULT_GROUP = 'postfix' - - verbose_name = _("Dovecot-Postfix virtualuser") - model = 'mailboxes.Mailbox' - - def set_user(self, context): - self.append(textwrap.dedent(""" - if grep '^%(user)s:' %(passwd_path)s > /dev/null ; then - sed -i 's#^%(user)s:.*#%(passwd)s#' %(passwd_path)s - else - echo '%(passwd)s' >> %(passwd_path)s - fi""") % context - ) - self.append("mkdir -p %(home)s" % context) - self.append("chown %(uid)s:%(gid)s %(home)s" % context) - - def set_mailbox(self, context): - self.append(textwrap.dedent(""" - 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 - UPDATED_VIRTUAL_MAILBOX_MAPS=1 - fi""") % context - ) - - def save(self, mailbox): - context = self.get_context(mailbox) - self.set_user(context) - self.set_mailbox(context) - self.generate_filter(mailbox, context) - - def delete(self, mailbox): - context = self.get_context(mailbox) - self.append(textwrap.dedent(""" - nohup bash -c 'sleep 2 && killall -u %(uid)s -s KILL' &> /dev/null & - killall -u %(uid)s || true - sed -i '/^%(user)s:.*/d' %(passwd_path)s - sed -i '/^%(user)s@%(mailbox_domain)s\s.*/d' %(virtual_mailbox_maps)s - UPDATED_VIRTUAL_MAILBOX_MAPS=1""") % context - ) - if context['deleted_home']: - self.append("mv %(home)s %(deleted_home)s || exit_code=$?" % context) - else: - self.append("rm -fr %(home)s" % context) - - def get_extra_fields(self, mailbox, context): - context['quota'] = self.get_quota(mailbox) - return 'userdb_mail=maildir:~/Maildir {quota}'.format(**context) - - def get_quota(self, mailbox): - try: - quota = mailbox.resources.disk.allocated - except (AttributeError, ObjectDoesNotExist): - return '' - unit = mailbox.resources.disk.unit[0].upper() - return 'userdb_quota_rule=*:bytes=%i%s' % (quota, unit) - - def commit(self): - context = { - 'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH - } - self.append(textwrap.dedent(""" - [[ $UPDATED_VIRTUAL_MAILBOX_MAPS == 1 ]] && { - postmap %(virtual_mailbox_maps)s - }""") % context - ) - - def get_context(self, mailbox): - context = { - 'name': mailbox.name, - 'user': mailbox.name, - 'password': mailbox.password if mailbox.active else '*%s' % mailbox.password, - 'uid': 10000 + mailbox.pk, - 'gid': 10000 + mailbox.pk, - 'group': self.DEFAULT_GROUP, - 'quota': self.get_quota(mailbox), - 'passwd_path': settings.MAILBOXES_PASSWD_PATH, - 'home': mailbox.get_home(), - 'banner': self.get_banner(), - 'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH, - 'mailbox_domain': settings.MAILBOXES_VIRTUAL_MAILBOX_DEFAULT_DOMAIN, - } - context['extra_fields'] = self.get_extra_fields(mailbox, context) - context.update({ - 'passwd': '{user}:{password}:{uid}:{gid}::{home}::{extra_fields}'.format(**context), - 'deleted_home': settings.MAILBOXES_MOVE_ON_DELETE_PATH % context, - }) - return context +#class DovecotPostfixPasswdVirtualUserBackend(SieveFilteringMixin, ServiceController): +# """ +# WARNING: This backends is not fully implemented +# """ +# DEFAULT_GROUP = 'postfix' +# +# verbose_name = _("Dovecot-Postfix virtualuser") +# model = 'mailboxes.Mailbox' +# +# def set_user(self, context): +# self.append(textwrap.dedent(""" +# if grep '^%(user)s:' %(passwd_path)s > /dev/null ; then +# sed -i 's#^%(user)s:.*#%(passwd)s#' %(passwd_path)s +# else +# echo '%(passwd)s' >> %(passwd_path)s +# fi""") % context +# ) +# self.append("mkdir -p %(home)s" % context) +# self.append("chown %(uid)s:%(gid)s %(home)s" % context) +# +# def set_mailbox(self, context): +# self.append(textwrap.dedent(""" +# 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 +# UPDATED_VIRTUAL_MAILBOX_MAPS=1 +# fi""") % context +# ) +# +# def save(self, mailbox): +# context = self.get_context(mailbox) +# self.set_user(context) +# self.set_mailbox(context) +# self.generate_filter(mailbox, context) +# +# def delete(self, mailbox): +# context = self.get_context(mailbox) +# self.append(textwrap.dedent(""" +# nohup bash -c 'sleep 2 && killall -u %(uid)s -s KILL' &> /dev/null & +# killall -u %(uid)s || true +# sed -i '/^%(user)s:.*/d' %(passwd_path)s +# sed -i '/^%(user)s@%(mailbox_domain)s\s.*/d' %(virtual_mailbox_maps)s +# UPDATED_VIRTUAL_MAILBOX_MAPS=1""") % context +# ) +# if context['deleted_home']: +# self.append("mv %(home)s %(deleted_home)s || exit_code=$?" % context) +# else: +# self.append("rm -fr %(home)s" % context) +# +# def get_extra_fields(self, mailbox, context): +# context['quota'] = self.get_quota(mailbox) +# return 'userdb_mail=maildir:~/Maildir {quota}'.format(**context) +# +# def get_quota(self, mailbox): +# try: +# quota = mailbox.resources.disk.allocated +# except (AttributeError, ObjectDoesNotExist): +# return '' +# unit = mailbox.resources.disk.unit[0].upper() +# return 'userdb_quota_rule=*:bytes=%i%s' % (quota, unit) +# +# def commit(self): +# context = { +# 'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH +# } +# self.append(textwrap.dedent(""" +# [[ $UPDATED_VIRTUAL_MAILBOX_MAPS == 1 ]] && { +# postmap %(virtual_mailbox_maps)s +# }""") % context +# ) +# +# def get_context(self, mailbox): +# context = { +# 'name': mailbox.name, +# 'user': mailbox.name, +# 'password': mailbox.password if mailbox.active else '*%s' % mailbox.password, +# 'uid': 10000 + mailbox.pk, +# 'gid': 10000 + mailbox.pk, +# 'group': self.DEFAULT_GROUP, +# 'quota': self.get_quota(mailbox), +# 'passwd_path': settings.MAILBOXES_PASSWD_PATH, +# 'home': mailbox.get_home(), +# 'banner': self.get_banner(), +# 'virtual_mailbox_maps': settings.MAILBOXES_VIRTUAL_MAILBOX_MAPS_PATH, +# 'mailbox_domain': settings.MAILBOXES_VIRTUAL_MAILBOX_DEFAULT_DOMAIN, +# } +# context['extra_fields'] = self.get_extra_fields(mailbox, context) +# context.update({ +# 'passwd': '{user}:{password}:{uid}:{gid}::{home}::{extra_fields}'.format(**context), +# 'deleted_home': settings.MAILBOXES_MOVE_ON_DELETE_PATH % context, +# }) +# return context class PostfixAddressVirtualDomainBackend(ServiceController): @@ -301,6 +301,7 @@ class PostfixAddressVirtualDomainBackend(ServiceController): def get_context(self, address): context = self.get_context_files() context.update({ + 'name': address.name, 'domain': address.domain, 'email': address.email, 'local_domain': settings.MAILBOXES_LOCAL_DOMAIN, @@ -319,10 +320,19 @@ class PostfixAddressBackend(PostfixAddressVirtualDomainBackend): '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): - destination = address.destination - if destination: - context['destination'] = destination + context['destination'] = address.destination + if not self.is_implicit_entry(context): self.append(textwrap.dedent(""" # Set virtual alias entry for %(email)s LINE='%(email)s\t%(destination)s' @@ -338,7 +348,12 @@ class PostfixAddressBackend(PostfixAddressVirtualDomainBackend): fi fi""") % context) 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) # Virtual mailbox stuff # destination = [] @@ -350,7 +365,7 @@ class PostfixAddressBackend(PostfixAddressVirtualDomainBackend): # destination.append(forward) def exclude_virtual_alias_maps(self, context): - self.append(textwrap.dedent(""" + self.append(textwrap.dedent("""\ # Remove %(email)s virtual alias entry if grep '^%(email)s\s' %(virtual_alias_maps)s > /dev/null; then sed -i '/^%(email)s\s/d' %(virtual_alias_maps)s diff --git a/orchestra/contrib/mailboxes/forms.py b/orchestra/contrib/mailboxes/forms.py index 549bc5f8..16034c6f 100644 --- a/orchestra/contrib/mailboxes/forms.py +++ b/orchestra/contrib/mailboxes/forms.py @@ -1,12 +1,14 @@ from django import forms from django.contrib.admin import widgets +from django.core.exceptions import ValidationError from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from orchestra.forms import UserCreationForm, UserChangeForm from orchestra.utils.python import AttrDict -from .models import Address +from . import settings +from .models import Address, Mailbox class MailboxForm(forms.ModelForm): @@ -37,21 +39,28 @@ class MailboxForm(forms.ModelForm): return mark_safe(output) self.fields['addresses'].widget.render = render 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 if self.instance and self.instance.pk: self.fields['addresses'].initial = self.instance.addresses.all() - def clean_custom_filtering(self): - # TODO move to model.clean? - filtering = self.cleaned_data['filtering'] - custom_filtering = self.cleaned_data['custom_filtering'] - if filtering == self._meta.model.CUSTOM and not custom_filtering: - raise forms.ValidationError({ - 'custom_filtering': _("You didn't provide any custom filtering.") - }) - return custom_filtering + def clean(self): + cleaned_data = super(MailboxForm, self).clean() + name = self.instance.name if self.instance.pk else cleaned_data.get('name') + local_domain = settings.MAILBOXES_LOCAL_DOMAIN + if name and local_domain: + try: + addr = Address.objects.get(name=name, domain__name=local_domain, account_id=self.modeladmin.account.pk) + except Address.DoesNotExist: + 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): @@ -73,5 +82,23 @@ class MailboxCreationForm(UserCreationForm, MailboxForm): class AddressForm(forms.ModelForm): def clean(self): cleaned_data = super(AddressForm, self).clean() - if not cleaned_data.get('mailboxes', True) and not cleaned_data['forward']: - raise forms.ValidationError(_("Mailboxes or forward address should be provided.")) + forward = cleaned_data.get('forward', '') + 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 diff --git a/orchestra/contrib/mailboxes/models.py b/orchestra/contrib/mailboxes/models.py index 4ce7a353..5a0d5340 100644 --- a/orchestra/contrib/mailboxes/models.py +++ b/orchestra/contrib/mailboxes/models.py @@ -1,4 +1,5 @@ import os +from collections import defaultdict from django.contrib.auth.hashers import make_password from django.core.validators import RegexValidator, ValidationError @@ -59,6 +60,10 @@ class Mailbox(models.Model): def clean(self): if self.custom_filtering and self.filtering != self.CUSTOM: 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): name, content = settings.MAILBOXES_MAILBOX_FILTERINGS[self.filtering] @@ -66,18 +71,6 @@ class Mailbox(models.Model): content = content(self) 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): if not settings.MAILBOXES_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) 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: - forward_errors = [] for mailbox in self.get_forward_mailboxes(): if mailbox.account_id == self.account_id: - forward_errors.append(ValidationError( + errors['forward'].append( _("Please use mailboxes field for '%s' mailbox.") % mailbox - )) - if forward_errors: - raise ValidationError({ - 'forward': forward_errors - }) + ) + if self.domain: + for forward in self.forward.split(): + if self.email == forward: + errors['forward'].append( + _("'%s' forwards to itself.") % forward + ) + if errors: + raise ValidationError(errors) def get_forward_mailboxes(self): for forward in self.forward.split(): diff --git a/orchestra/contrib/mailboxes/signals.py b/orchestra/contrib/mailboxes/signals.py new file mode 100644 index 00000000..467ce89b --- /dev/null +++ b/orchestra/contrib/mailboxes/signals.py @@ -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',)) diff --git a/orchestra/contrib/orchestration/admin.py b/orchestra/contrib/orchestration/admin.py index 7afc245e..19c9a876 100644 --- a/orchestra/contrib/orchestration/admin.py +++ b/orchestra/contrib/orchestration/admin.py @@ -94,7 +94,8 @@ class RouteAdmin(ExtendedModelAdmin): def changeform_view(self, request, object_id=None, form_url='', extra_context=None): 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): diff --git a/orchestra/contrib/webapps/backends/__init__.py b/orchestra/contrib/webapps/backends/__init__.py index da79b48d..4680c274 100644 --- a/orchestra/contrib/webapps/backends/__init__.py +++ b/orchestra/contrib/webapps/backends/__init__.py @@ -28,11 +28,11 @@ class WebAppServiceMixin(object): if context['under_construction_path']: self.append(textwrap.dedent(""" # Set under construction if needed - if [[ $CREATED == 1 && ! $(ls -A %(app_path)s) ]]; then - # Async wait 2 more seconds for other backends to lock app_path or cp under construction + if [[ $CREATED == 1 && ! $(ls -A %(app_path)s | head -n1) ]]; then + # Async wait some seconds for other backends to lock app_path or cp under construction nohup bash -c ' 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 chown -R %(user)s:%(group)s %(app_path)s fi' &> /dev/null & diff --git a/orchestra/utils/humanize.py b/orchestra/utils/humanize.py index d87accc8..ec64a312 100644 --- a/orchestra/utils/humanize.py +++ b/orchestra/utils/humanize.py @@ -4,34 +4,19 @@ from django.utils import timezone 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( - _('{num:.1f} year{ago}'), - _('{num:.1f} years{ago}'), n) - - -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) + _("{n:.1f} {s_units} ago"), + _("{n:.1f} {units} ago"), n + ).format(n=n, units=units, s_units=units[:-1]) OLDER_CHUNKS = ( - (365.0, pluralize_year), - (30.0, pluralize_month), - (7.0, pluralize_week), + (365.0, 'years'), + (30.0, 'months'), + (7.0, 'weeks'), ) @@ -52,50 +37,34 @@ def naturaldatetime(date, show_seconds=False): delta_midnight = today - date days = delta.days - hours = int(round(delta.seconds / 3600, 0)) - minutes = delta.seconds / 60 + hours = float(delta.seconds) / 3600 + minutes = float(delta.seconds) / 60 seconds = delta.seconds - ago = " ago" - if days < 0: - ago = "" days = abs(days) if days == 0: - if hours == 0: + if int(hours) == 0: if minutes >= 1 or not show_seconds: - minutes = float(seconds)/60 - return ungettext( - _("{minutes:.1f} minute{ago}"), - _("{minutes:.1f} minutes{ago}"), minutes - ).format(minutes=minutes, ago=ago) + return verbose_time(minutes, 'minutes') else: - return ungettext( - _("{seconds} second{ago}"), - _("{seconds} seconds{ago}"), seconds - ).format(seconds=seconds, ago=ago) + return verbose_time(seconds, 'seconds') else: - hours = float(minutes)/60 - return ungettext( - _("{hours:.1f} hour{ago}"), - _("{hours:.1f} hours{ago}"), hours - ).format(hours=hours, ago=ago) + return verbose_time(hours, 'hours') if delta_midnight.days == 0: date = timezone.localtime(date) return _("yesterday at {time}").format(time=date.strftime('%H:%M')) count = 0 - for chunk, pluralizefun in OLDER_CHUNKS: + for chunk, units in OLDER_CHUNKS: if days < 7.0: count = days + float(hours)/24 - fmt = pluralize_day(count) - return fmt.format(num=count, ago=ago) + return verbose_time(count, 'days') if days >= chunk: count = (delta_midnight.days + 1) / chunk count = abs(count) - fmt = pluralizefun(count) - return fmt.format(num=count, ago=ago) + return verbose_time(count, units) def naturaldate(date):