From e57226b7693da6da44b6c5554a8447f236651d34 Mon Sep 17 00:00:00 2001 From: Marc Date: Tue, 22 Jul 2014 21:47:01 +0000 Subject: [PATCH] Admin interface improvements --- orchestra/admin/utils.py | 58 ++++++++++++------ orchestra/apps/invoices/__init__.py | 0 orchestra/apps/issues/admin.py | 13 ++-- orchestra/apps/miscellaneous/admin.py | 2 +- orchestra/apps/orchestration/admin.py | 27 ++------- orchestra/apps/orders/admin.py | 27 +++++++-- orchestra/apps/orders/models.py | 33 +++++----- orchestra/apps/resources/admin.py | 23 +++---- orchestra/apps/resources/models.py | 11 ++-- orchestra/utils/humanize.py | 87 +++++++++++++++++++++++++++ 10 files changed, 192 insertions(+), 89 deletions(-) create mode 100644 orchestra/apps/invoices/__init__.py create mode 100644 orchestra/utils/humanize.py diff --git a/orchestra/admin/utils.py b/orchestra/admin/utils.py index d378968d..9340e8ed 100644 --- a/orchestra/admin/utils.py +++ b/orchestra/admin/utils.py @@ -10,7 +10,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from orchestra.models.utils import get_field_value -from orchestra.utils.time import timesince, timeuntil +from orchestra.utils.humanize import naturaldate def get_modeladmin(model, import_module=True): @@ -78,7 +78,7 @@ def admin_link(*args, **kwargs): def display_link(self, instance): obj = getattr(instance, field, instance) - if not getattr(obj, 'pk', False): + if not getattr(obj, 'pk', None): return '---' opts = obj._meta view_name = 'admin:%s_%s_change' % (opts.app_label, opts.model_name) @@ -95,7 +95,7 @@ def admin_link(*args, **kwargs): def colored(field_name, colours, description='', verbose=False, bold=True): """ returns a method that will render obj with colored html """ - def colored_field(obj, field=field_name, colors=colours, verbose=verbose): + def colored_field(modeladmin, obj, field=field_name, colors=colours, verbose=verbose): value = escape(get_field_value(obj, field)) color = colors.get(value, "black") if verbose: @@ -113,22 +113,40 @@ def colored(field_name, colours, description='', verbose=False, bold=True): return colored_field -def display_timesince(date, double=False): - """ - Format date for messages create_on: show a relative time - with contextual helper to show fulltime format. - """ - if not date: - return 'Never' - date_rel = timesince(date) - if not double: - date_rel = date_rel.split(',')[0] - date_rel += ' ago' - date_abs = date.strftime("%Y-%m-%d %H:%M:%S %Z") - return mark_safe("%s" % (date_abs, date_rel)) +#def display_timesince(date, double=False): +# """ +# Format date for messages create_on: show a relative time +# with contextual helper to show fulltime format. +# """ +# if not date: +# return 'Never' +# date_rel = timesince(date) +# if not double: +# date_rel = date_rel.split(',')[0] +# date_rel += ' ago' +# date_abs = date.strftime("%Y-%m-%d %H:%M:%S %Z") +# return mark_safe("%s" % (date_abs, date_rel)) -def display_timeuntil(date): - date_rel = timeuntil(date) + ' left' - date_abs = date.strftime("%Y-%m-%d %H:%M:%S %Z") - return mark_safe("%s" % (date_abs, date_rel)) +def admin_date(field, **kwargs): + """ utility function for creating admin dates """ + default = kwargs.pop('default', '') + order = kwargs.pop('order', field) + + def display_date(self, instance): + value = get_field_value(instance, field) + if not value: + return default + return '
{1}
'.format( + escape(str(value)), escape(naturaldate(value)), + ) + display_date.short_description = _(field.replace('_', ' ')) + display_date.admin_order_field = order + display_date.allow_tags = True + return display_date + + +#def display_timeuntil(date): +# date_rel = timeuntil(date) + ' left' +# date_abs = date.strftime("%Y-%m-%d %H:%M:%S %Z") +# return mark_safe("%s" % (date_abs, date_rel)) diff --git a/orchestra/apps/invoices/__init__.py b/orchestra/apps/invoices/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/orchestra/apps/issues/admin.py b/orchestra/apps/issues/admin.py index 5e0ef9ff..4f37dc7c 100644 --- a/orchestra/apps/issues/admin.py +++ b/orchestra/apps/issues/admin.py @@ -12,8 +12,7 @@ from django.utils.translation import ugettext_lazy as _ from markdown import markdown from orchestra.admin import ChangeListDefaultFilter, ExtendedModelAdmin#, ChangeViewActions -from orchestra.admin.utils import (admin_link, colored, wrap_admin_view, - display_timesince) +from orchestra.admin.utils import admin_link, colored, wrap_admin_view, admin_date from orchestra.apps.contacts import settings as contacts_settings from .actions import (reject_tickets, resolve_tickets, take_tickets, close_tickets, @@ -110,6 +109,8 @@ class TicketInline(admin.TabularInline): creator_link = admin_link('creator') owner_link = admin_link('owner') + created = admin_link('created_on') + last_modified = admin_link('last_modified_on') def ticket_id(self, instance): return '%s' % link()(self, instance) @@ -123,12 +124,6 @@ class TicketInline(admin.TabularInline): def colored_priority(self, instance): return colored('priority', PRIORITY_COLORS, bold=False)(instance) colored_priority.short_description = _("Priority") - - def created(self, instance): - return display_timesince(instance.created_on) - - def last_modified(self, instance): - return display_timesince(instance.last_modified_on) class TicketAdmin(ChangeListDefaultFilter, ExtendedModelAdmin): #TODO ChangeViewActions, @@ -327,7 +322,7 @@ class QueueAdmin(admin.ModelAdmin): } def num_tickets(self, queue): - num = queue.tickets.count() + num = queue.tickets__count url = reverse('admin:issues_ticket_changelist') url += '?my_tickets=False&queue=%i' % queue.pk return '%d' % (url, num) diff --git a/orchestra/apps/miscellaneous/admin.py b/orchestra/apps/miscellaneous/admin.py index dc623b1a..1b506b30 100644 --- a/orchestra/apps/miscellaneous/admin.py +++ b/orchestra/apps/miscellaneous/admin.py @@ -14,7 +14,7 @@ class MiscServiceAdmin(admin.ModelAdmin): def num_instances(self, misc): """ return num slivers as a link to slivers changelist view """ - num = misc.instances.count() + num = misc.instances__count url = reverse('admin:miscellaneous_miscellaneous_changelist') url += '?service={}'.format(misc.pk) return mark_safe('{1}'.format(url, num)) diff --git a/orchestra/apps/orchestration/admin.py b/orchestra/apps/orchestration/admin.py index fa06ebe9..6cdd0059 100644 --- a/orchestra/apps/orchestration/admin.py +++ b/orchestra/apps/orchestration/admin.py @@ -2,10 +2,9 @@ from django.contrib import admin from django.core.urlresolvers import reverse from django.utils.html import escape from django.utils.translation import ugettext_lazy as _ -from djcelery.humanize import naturaldate from orchestra.admin.html import monospace_format -from orchestra.admin.utils import admin_link +from orchestra.admin.utils import admin_link, admin_date, colored from .models import Server, Route, BackendLog, BackendOperation @@ -89,13 +88,9 @@ class BackendLogAdmin(admin.ModelAdmin): readonly_fields = fields server_link = admin_link('server') - - def display_state(self, log): - color = STATE_COLORS.get(log.state, 'grey') - return '%s' % (color, log.state) - display_state.short_description = _("state") - display_state.allow_tags = True - display_state.admin_order_field = 'state' + display_last_update = admin_date('last_update') + display_created = admin_date('created') + display_state = colored('state', STATE_COLORS) def mono_script(self, log): return monospace_format(escape(log.script)) @@ -113,20 +108,6 @@ class BackendLogAdmin(admin.ModelAdmin): return monospace_format(escape(log.traceback)) mono_traceback.short_description = _("traceback") - def display_last_update(self, log): - return '
{1}
'.format( - escape(str(log.last_update)), escape(naturaldate(log.last_update)), - ) - display_last_update.short_description = _("last update") - display_last_update.allow_tags = True - - def display_created(self, log): - return '
{1}
'.format( - escape(str(log.created)), escape(naturaldate(log.created)), - ) - display_created.short_description = _("created") - display_created.allow_tags = True - def get_queryset(self, request): """ Order by structured name and imporve performance """ qs = super(BackendLogAdmin, self).get_queryset(request) diff --git a/orchestra/apps/orders/admin.py b/orchestra/apps/orders/admin.py index d949dd19..b79badc7 100644 --- a/orchestra/apps/orders/admin.py +++ b/orchestra/apps/orders/admin.py @@ -2,11 +2,12 @@ from django import forms from django.db import models from django.contrib import admin from django.core.urlresolvers import reverse +from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from orchestra.admin import ChangeListDefaultFilter from orchestra.admin.filters import UsedContentTypeFilter -from orchestra.admin.utils import admin_link +from orchestra.admin.utils import admin_link, admin_date from orchestra.apps.accounts.admin import AccountAdminMixin from orchestra.core import services @@ -49,9 +50,9 @@ class ServiceAdmin(admin.ModelAdmin): return super(ServiceAdmin, self).formfield_for_dbfield(db_field, **kwargs) def num_orders(self, service): - num = service.orders.count() + num = service.orders__count url = reverse('admin:orders_order_changelist') - url += '?service=%i' % service.pk + url += '?service=%i&is_active=True' % service.pk return '%d' % (url, num) num_orders.short_description = _("Orders") num_orders.admin_order_field = 'orders__count' @@ -59,20 +60,36 @@ class ServiceAdmin(admin.ModelAdmin): def get_queryset(self, request): qs = super(ServiceAdmin, self).get_queryset(request) - qs = qs.annotate(models.Count('orders')) + # Count active orders + qs = qs.extra(select={ + 'orders__count': ( + "SELECT COUNT(*) " + "FROM orders_order " + "WHERE orders_order.service_id = orders_service.id AND (" + " orders_order.cancelled_on IS NULL OR" + " orders_order.cancelled_on > '%s' " + ")" % timezone.now() + ) + }) return qs class OrderAdmin(AccountAdminMixin, ChangeListDefaultFilter, admin.ModelAdmin): list_display = ( - 'id', 'service', 'account_link', 'content_object_link', 'cancelled_on' + 'id', 'service', 'account_link', 'content_object_link', + 'display_registered_on', 'display_cancelled_on' ) + list_display_link = ('id', 'service') list_filter = (ActiveOrderListFilter, 'service',) + date_hierarchy = 'registered_on' default_changelist_filters = ( ('is_active', 'True'), ) content_object_link = admin_link('content_object') + display_registered_on = admin_date('registered_on') + display_cancelled_on = admin_date('cancelled_on') + class MetricStorageAdmin(admin.ModelAdmin): list_display = ('order', 'value', 'created_on', 'updated_on') diff --git a/orchestra/apps/orders/models.py b/orchestra/apps/orders/models.py index a24d24ba..817835cd 100644 --- a/orchestra/apps/orders/models.py +++ b/orchestra/apps/orders/models.py @@ -10,7 +10,7 @@ from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ -from orchestra.core import caches +from orchestra.core import caches, services from orchestra.utils.apps import autodiscover from . import settings @@ -195,14 +195,22 @@ class Service(models.Model): except IndexError: pass else: - for attr in ['matches', 'get_metric']: - try: - getattr(self.handler, attr)(obj) - except Exception as exception: - name = type(exception).__name__ - message = exception.message - msg = "{0} {1}: {2}".format(attr, name, message) - raise ValidationError(msg) + attr = None + try: + bool(self.handler.matches(obj)) + except Exception as exception: + attr = "Matches" + try: + metric = self.handler.get_metric(obj) + if metric is not None: + int(metric) + except Exception as exception: + attr = "Get metric" + if attr is not None: + name = type(exception).__name__ + message = exception.message + msg = "{0} {1}: {2}".format(attr, name, message) + raise ValidationError(msg) class OrderQuerySet(models.QuerySet): @@ -222,9 +230,6 @@ class OrderQuerySet(models.QuerySet): class Order(models.Model): - SAVE = 'SAVE' - DELETE = 'DELETE' - account = models.ForeignKey('accounts.Account', verbose_name=_("account"), related_name='orders') content_type = models.ForeignKey(ContentType) @@ -303,14 +308,14 @@ class MetricStorage(models.Model): @receiver(pre_delete, dispatch_uid="orders.cancel_orders") def cancel_orders(sender, **kwargs): - if sender not in [MetricStorage, LogEntry, Order, Service]: + if sender in services: instance = kwargs['instance'] for order in Order.objects.by_object(instance).active(): order.cancel() @receiver(post_save, dispatch_uid="orders.update_orders") -@receiver(post_delete, dispatch_uid="orders.update_orders") +@receiver(post_delete, dispatch_uid="orders.update_orders_post_delete") def update_orders(sender, **kwargs): if sender not in [MetricStorage, LogEntry, Order, Service]: instance = kwargs['instance'] diff --git a/orchestra/apps/resources/admin.py b/orchestra/apps/resources/admin.py index 7bbb55f7..eefb22e6 100644 --- a/orchestra/apps/resources/admin.py +++ b/orchestra/apps/resources/admin.py @@ -1,13 +1,11 @@ from django.contrib import admin, messages from django.contrib.contenttypes import generic from django.utils.functional import cached_property -from django.utils.html import escape from django.utils.translation import ugettext_lazy as _ -from djcelery.humanize import naturaldate from orchestra.admin import ExtendedModelAdmin from orchestra.admin.filters import UsedContentTypeFilter -from orchestra.admin.utils import insertattr, get_modeladmin, admin_link +from orchestra.admin.utils import insertattr, get_modeladmin, admin_link, admin_date from orchestra.core import services from orchestra.utils import running_syncdb @@ -91,13 +89,17 @@ admin.site.register(MonitorData, MonitorDataAdmin) def resource_inline_factory(resources): class ResourceInlineFormSet(generic.BaseGenericInlineFormSet): - def total_form_count(self): + def total_form_count(self, resources=resources): return len(resources) @cached_property - def forms(self): + def forms(self, resources=resources): forms = [] - for i, resource in enumerate(resources): + resources_copy = list(resources) + for i, data in enumerate(self.queryset): + forms.append(self._construct_form(i, resource=data.resource)) + resources_copy.remove(data.resource) + for i, resource in enumerate(resources_copy, len(self.queryset)): forms.append(self._construct_form(i, resource=resource)) return forms @@ -117,16 +119,11 @@ def resource_inline_factory(resources): 'all': ('orchestra/css/hide-inline-id.css',) } + display_last_update = admin_date('last_update', default=_("Never")) + def has_add_permission(self, *args, **kwargs): """ Hidde add another """ return False - - def display_last_update(self, data): - return '
{1}
'.format( - escape(str(data.last_update)), escape(naturaldate(data.last_update)), - ) - display_last_update.short_description = _("last update") - display_last_update.allow_tags = True return ResourceInline diff --git a/orchestra/apps/resources/models.py b/orchestra/apps/resources/models.py index 14a54027..51a0c664 100644 --- a/orchestra/apps/resources/models.py +++ b/orchestra/apps/resources/models.py @@ -6,6 +6,7 @@ from django.utils.translation import ugettext_lazy as _ from djcelery.models import PeriodicTask, CrontabSchedule from orchestra.models.fields import MultiSelectField +from orchestra.utils.functional import cached from . import helpers from .backends import ServiceMonitor @@ -164,15 +165,17 @@ def create_resource_relation(): class ResourceHandler(object): """ account.resources.web """ def __getattr__(self, attr): - """ get or create ResourceData """ + """ get or build ResourceData """ try: - return self.obj.resource_set.get(resource__name=attr) + data = self.obj.resource_set.get(resource__name=attr) except ResourceData.DoesNotExist: model = self.obj._meta.model_name resource = Resource.objects.get(content_type__model=model, name=attr, is_active=True) - return ResourceData.objects.create(content_object=self.obj, - resource=resource) + data = ResourceData(content_object=self.obj, resource=resource) + print data.resource_id, data.content_type_id, data.object_id + setattr(self, attr, data) + return data def __get__(self, obj, cls): self.obj = obj diff --git a/orchestra/utils/humanize.py b/orchestra/utils/humanize.py new file mode 100644 index 00000000..cbfd944f --- /dev/null +++ b/orchestra/utils/humanize.py @@ -0,0 +1,87 @@ +from datetime import datetime + +from django.utils import timezone +from django.utils.translation import ungettext, ugettext as _ + + +def pluralize_year(n): + 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) + + +OLDER_CHUNKS = ( + (365.0, pluralize_year), + (30.0, pluralize_month), + (7.0, pluralize_week), +) + + +def _un(singular__plural, n=None): + singular, plural = singular__plural + return ungettext(singular, plural, n) + + +def naturaldate(date, include_seconds=False): + """Convert datetime into a human natural date string.""" + if not date: + return '' + + right_now = timezone.now() + today = datetime(right_now.year, right_now.month, + right_now.day, tzinfo=right_now.tzinfo) + delta = right_now - date + delta_midnight = today - date + + days = delta.days + hours = int(round(delta.seconds / 3600, 0)) + minutes = delta.seconds / 60 + seconds = delta.seconds + + if days < 0: + return _('just now') + + if days == 0: + if hours == 0: + if minutes > 0: + minutes += float(seconds)/60 + return ungettext( + _('{minutes:.1f} minute ago'), + _('{minutes:.1f} minutes ago'), minutes + ).format(minutes=minutes) + else: + if include_seconds and seconds: + return ungettext( + _('{seconds} second ago'), + _('{seconds} seconds ago'), seconds + ).format(seconds=seconds) + return _('just now') + else: + hours += float(minutes)/60 + return ungettext( + _('{hours:.1f} hour ago'), _('{hours:.1f} hours ago'), hours + ).format(hours=hours) + + if delta_midnight.days == 0: + return _('yesterday at {time}').format(time=date.strftime('%H:%M')) + + count = 0 + for chunk, pluralizefun in OLDER_CHUNKS: + if days < 7.0: + count = days + float(hours)/24 + fmt = pluralize_day(count) + return fmt.format(num=count) + if days >= chunk: + count = (delta_midnight.days + 1) / chunk + fmt = pluralizefun(count) + return fmt.format(num=count)