diff --git a/djadmin2/filters.py b/djadmin2/filters.py index 763a85b..4151b94 100644 --- a/djadmin2/filters.py +++ b/djadmin2/filters.py @@ -4,6 +4,7 @@ from __future__ import division, absolute_import, unicode_literals import collections from itertools import chain +from django import forms from django.forms.util import flatatt from django.utils.html import format_html from django.utils.encoding import force_text @@ -16,6 +17,10 @@ import django_filters LINK_TEMPLATE = '{3}' +class NumericDateFilter(django_filters.DateFilter): + field_class = forms.IntegerField + + class ChoicesAsLinksWidget(django_widgets.Select): """Select form widget taht renders links for choices instead of select element with options. @@ -95,6 +100,29 @@ def build_list_filter(request, model_admin, queryset): )(request.GET, queryset=queryset) +def build_date_filter(request, model_admin, queryset): + filterset_dict = { + "year": NumericDateFilter( + name="published_date", + lookup_type="year", + ), + "month": NumericDateFilter( + name="published_date", + lookup_type="month", + ), + "day": NumericDateFilter( + name="published_date", + lookup_type="day", + ) + } + + return type( + b'%sDateFilterSet' % queryset.model.__name__, + (django_filters.FilterSet,), + filterset_dict, + )(request.GET, queryset=queryset) + + def get_filter_for_field_name(model, field_name): """Returns filter for model field by field name. """ diff --git a/djadmin2/settings.py b/djadmin2/settings.py index 5153979..1203d0e 100644 --- a/djadmin2/settings.py +++ b/djadmin2/settings.py @@ -8,12 +8,11 @@ from django.conf import settings # views. This is a security feature. # See the docstring on djadmin2.types.ModelAdmin2 for more detail. MODEL_ADMIN_ATTRS = ( - 'actions_selection_counter', 'list_display', 'list_display_links', - 'list_filter', 'admin', 'search_fields', 'field_renderers', - 'index_view', 'detail_view', 'create_view', 'update_view', 'delete_view', - 'get_default_view_kwargs', 'get_list_actions', - 'actions_on_bottom', 'actions_on_top', - 'save_on_top', 'save_on_bottom', - 'readonly_fields', ) + 'actions_selection_counter', "date_hierarchy", 'list_display', + 'list_display_links', 'list_filter', 'admin', 'search_fields', + 'field_renderers', 'index_view', 'detail_view', 'create_view', + 'update_view', 'delete_view', 'get_default_view_kwargs', + 'get_list_actions', 'actions_on_bottom', 'actions_on_top', + 'save_on_top', 'save_on_bottom', 'readonly_fields', ) ADMIN2_THEME_DIRECTORY = getattr(settings, "ADMIN2_THEME_DIRECTORY", "djadmin2theme_default") diff --git a/djadmin2/static/themes/bootstrap/css/base.css b/djadmin2/static/themes/bootstrap/css/base.css index 773ef1b..62307e2 100644 --- a/djadmin2/static/themes/bootstrap/css/base.css +++ b/djadmin2/static/themes/bootstrap/css/base.css @@ -7,3 +7,12 @@ .sort_link:hover { color: black; } + +.previous-link a { + color: gray; +} + +.date-drilldown { + padding-bottom: 0; + padding-top: 0; +} diff --git a/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/model_list.html b/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/model_list.html index b36224f..c425d4a 100644 --- a/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/model_list.html +++ b/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/model_list.html @@ -36,13 +36,29 @@ {% endif %} -
{% csrf_token %}
+ {% if dates %} + + {% endif %} + {% if view.model_admin.actions_on_top %} {% include 'djadmin2theme_default/includes/list_actions.html' with position='top' %} {% endif %} diff --git a/djadmin2/types.py b/djadmin2/types.py index c1c9cd6..108d43a 100644 --- a/djadmin2/types.py +++ b/djadmin2/types.py @@ -59,6 +59,7 @@ class ModelAdmin2(with_metaclass(ModelAdminBase2)): bypass the blocking features of the ImmutableAdmin. """ actions_selection_counter = True + date_hierarchy = False list_display = ('__str__',) list_display_links = () list_filter = () diff --git a/djadmin2/views.py b/djadmin2/views.py index 2883f22..c5a627d 100644 --- a/djadmin2/views.py +++ b/djadmin2/views.py @@ -2,6 +2,7 @@ from __future__ import division, absolute_import, unicode_literals import operator +from datetime import datetime from django.contrib.auth import get_user_model from django.contrib.auth.forms import (PasswordChangeForm, @@ -26,7 +27,7 @@ from . import permissions, utils from .forms import AdminAuthenticationForm from .models import LogEntry from .viewmixins import Admin2Mixin, AdminModel2Mixin, Admin2ModelFormMixin -from .filters import build_list_filter +from .filters import build_list_filter, build_date_filter class AdminView(object): @@ -155,6 +156,9 @@ class ModelListView(AdminModel2Mixin, generic.ListView): if self.model_admin.list_filter: queryset = self.build_list_filter(queryset).qs + if self.model_admin.date_hierarchy: + queryset = self.build_date_filter(queryset).qs + queryset = self._modify_queryset_for_sort(queryset) if search_use_distinct: @@ -194,6 +198,18 @@ class ModelListView(AdminModel2Mixin, generic.ListView): ) return self._list_filter + def build_date_filter(self, queryset=None): + if not hasattr(self, "_date_filter"): + if queryset is None: + queryset = self.get_queryset() + self._date_filter = build_date_filter( + self.request, + self.model_admin, + queryset, + ) + + return self._date_filter + def get_context_data(self, **kwargs): context = super(ModelListView, self).get_context_data(**kwargs) context['model'] = self.get_model() @@ -202,8 +218,79 @@ class ModelListView(AdminModel2Mixin, generic.ListView): context['search_term'] = self.request.GET.get('q', '') context['list_filter'] = self.build_list_filter() context['sort_term'] = self.request.GET.get('sort', '') + + if self.model_admin.date_hierarchy: + year = self.request.GET.get("year", False) + month = self.request.GET.get("month", False) + day = self.request.GET.get("day", False) + + if year and month and day: + new_date = datetime.strptime( + "%s %s %s" % (month, day, year), + "%m %d %Y", + ) + context["previous_date"] = { + "link": "?year=%s&month=%s" % (year, month), + "text": "‹ %s" % new_date.strftime("%B %Y") + } + + context["active_day"] = new_date.strftime("%B %d") + + context["dates"] = self._format_days(context) + elif year and month: + context["previous_date"] = { + "link": "?year=%s" % (year), + "text": "‹ %s" % year, + } + + context["dates"] = self._format_days(context) + elif year: + context["previous_date"] = { + "link": "?", + "text": ugettext_lazy("‹ All dates"), + } + + context["dates"] = self._format_months(context) + else: + context["dates"] = self._format_years(context) + return context + def _format_years(self, context): + years = context['object_list'].dates('published_date', 'year') + if len(years) == 1: + return self._format_months(context) + else: + return [ + (("?year=%s" % year.strftime("%Y")), year.strftime("%Y")) + for year in + context['object_list'].dates('published_date', 'year') + ] + + def _format_months(self, context): + return [ + ( + "?year=%s&month=%s" % ( + date.strftime("%Y"), date.strftime("%m") + ), + date.strftime("%B %Y") + ) for date in + context["object_list"].dates('published_date', 'month') + ] + + def _format_days(self, context): + return [ + ( + "?year=%s&month=%s&day=%s" % ( + date.strftime("%Y"), + date.strftime("%m"), + date.strftime("%d"), + ), + date.strftime("%B %d") + ) for date in + context["object_list"].dates('published_date', 'day') + ] + def get_success_url(self): view_name = 'admin2:{}_{}_index'.format( self.app_label, self.model_name) diff --git a/example/blog/admin.py b/example/blog/admin.py index d5e95bd..545806c 100644 --- a/example/blog/admin.py +++ b/example/blog/admin.py @@ -12,8 +12,9 @@ class CommentInline(admin.TabularInline): class PostAdmin(admin.ModelAdmin): inlines = [CommentInline, ] - search_fields = ('title', 'body') + search_fields = ('title', 'body', "published_date") list_filter = ['published', 'title'] + date_hierarchy = "published_date" admin.site.register(Post, PostAdmin) admin.site.register(Comment) diff --git a/example/blog/admin2.py b/example/blog/admin2.py index 5b9739d..fb0d222 100644 --- a/example/blog/admin2.py +++ b/example/blog/admin2.py @@ -29,11 +29,12 @@ class PostAdmin(djadmin2.ModelAdmin2): list_actions = [DeleteSelectedAction, CustomPublishAction, unpublish_items] inlines = [CommentInline] search_fields = ('title', '^body') - list_display = ('title', 'body', 'published') + list_display = ('title', 'body', 'published', "published_date",) field_renderers = { 'title': renderers.title_renderer, } save_on_top = True + date_hierarchy = "published_date" class CommentAdmin(djadmin2.ModelAdmin2): diff --git a/example/blog/models.py b/example/blog/models.py index 7222041..e3503f0 100644 --- a/example/blog/models.py +++ b/example/blog/models.py @@ -11,6 +11,7 @@ class Post(models.Model): title = models.CharField(max_length=255, verbose_name=_('title')) body = models.TextField(verbose_name=_('body')) published = models.BooleanField(default=False, verbose_name=_('published')) + published_date = models.DateField(blank=True, null=True) def __unicode__(self): return self.title diff --git a/example/blog/tests/test_views.py b/example/blog/tests/test_views.py index b9827fd..5eec156 100644 --- a/example/blog/tests/test_views.py +++ b/example/blog/tests/test_views.py @@ -1,3 +1,6 @@ +# -*- coding: utf-8 -*- +from datetime import datetime + from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core.urlresolvers import reverse @@ -59,6 +62,46 @@ class CommentListTest(BaseIntegrationTest): class PostListTest(BaseIntegrationTest): + def _create_posts(self): + Post.objects.bulk_create([ + Post( + title="post_1_title", + body="body", + published_date=datetime( + month=7, + day=22, + year=2013 + ) + ), + Post( + title="post_2_title", + body="body", + published_date=datetime( + month=5, + day=20, + year=2012, + ) + ), + Post( + title="post_3_title", + body="body", + published_date=datetime( + month=5, + day=30, + year=2012, + ), + ), + Post( + title="post_4_title", + body="body", + published_date=datetime( + month=6, + day=20, + year=2012, + ) + ) + ]) + def test_view_ok(self): post = Post.objects.create(title="A Post Title", body="body") response = self.client.get(reverse("admin2:blog_post_index")) @@ -134,6 +177,65 @@ class PostListTest(BaseIntegrationTest): response = self.client.get(reverse('admin2:blog_post_index')) self.assertContains(response, 'icon-ok-sign') + def test_drilldowns(self): + self._create_posts() + + response = self.client.get(reverse('admin2:blog_post_index')) + self.assertContains(response, '2012') + self.assertContains(response, "", 4) + + response = self.client.get( + "%s?%s" % ( + reverse('admin2:blog_post_index'), + "year=2012", + ) + ) + + self.assertContains( + response, + 'May 2012', + ) + self.assertContains( + response, + 'All dates', + ) + self.assertContains(response, "", 3) + + response = self.client.get( + "%s?%s" % ( + reverse('admin2:blog_post_index'), + "year=2012&month=5", + ) + ) + + self.assertContains(response, "", 2) + self.assertContains( + response, + 'May 20', + ) + self.assertContains(response, '') + + response = self.client.get( + "%s?%s" % ( + reverse('admin2:blog_post_index'), + "year=2012&month=05&day=20", + ) + ) + + self.assertContains(response, "", 1) + self.assertContains( + response, + 'May 20', + ) + self.assertContains( + response, + '
  • ' + ) + self.assertContains( + response, + 'May 2012' + ) + class PostListTestCustomAction(BaseIntegrationTest):