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?
* 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?

View File

@ -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',

View File

@ -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)

View File

@ -82,18 +82,15 @@ 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 """

View File

@ -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 <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):
""" 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']

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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():

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):
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):

View File

@ -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 &

View File

@ -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):