diff --git a/djadmin2/core.py b/djadmin2/core.py index ce60da2d..509cee9a 100644 --- a/djadmin2/core.py +++ b/djadmin2/core.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*-: """ -WARNING: This file about to undergo major refactoring by @pydanny per Issue #99. +WARNING: This file about to undergo major refactoring by @pydanny per +Issue #99. """ from __future__ import division, absolute_import, unicode_literals @@ -22,7 +23,8 @@ class Admin2(object): It keeps a registry of all registered Models and collects the urls of their related ModelAdmin2 instances. - It also provides an index view that serves as an entry point to the admin site. + It also provides an index view that serves as an entry point to the + admin site. """ index_view = views.IndexView app_index_view = views.AppIndexView @@ -46,7 +48,8 @@ def register(self, model, model_admin=None, **kwargs): If a model is already registered, this will raise ImproperlyConfigured. """ if model in self.registry: - raise ImproperlyConfigured('%s is already registered in django-admin2' % model) + raise ImproperlyConfigured( + '%s is already registered in django-admin2' % model) if not model_admin: model_admin = types.ModelAdmin2 self.registry[model] = model_admin(model, admin=self, **kwargs) @@ -62,12 +65,14 @@ def deregister(self, model): """ Deregisters the given model. Remove the model from the self.app as well - If the model is not already registered, this will raise ImproperlyConfigured. + If the model is not already registered, this will raise + ImproperlyConfigured. """ try: del self.registry[model] except KeyError: - raise ImproperlyConfigured('%s was never registered in django-admin2' % model) + raise ImproperlyConfigured( + '%s was never registered in django-admin2' % model) # Remove the model from the apps registry # Get the app label @@ -101,7 +106,8 @@ def get_admin_by_name(self, name): for object_admin in self.registry.values(): if object_admin.name == name: return object_admin - raise ValueError(u'No object admin found with name {}'.format(repr(name))) + raise ValueError( + u'No object admin found with name {}'.format(repr(name))) def get_index_kwargs(self): return { @@ -122,7 +128,8 @@ def get_api_index_kwargs(self): } def get_urls(self): - urlpatterns = patterns('', + urlpatterns = patterns( + '', url(regex=r'^$', view=self.index_view.as_view(**self.get_index_kwargs()), name='dashboard' @@ -140,18 +147,21 @@ def get_urls(self): name='logout' ), url(regex=r'^(?P\w+)/$', - view=self.app_index_view.as_view(**self.get_app_index_kwargs()), + view=self.app_index_view.as_view( + **self.get_app_index_kwargs()), name='app_index' ), url(regex=r'^api/v0/$', - view=self.api_index_view.as_view(**self.get_api_index_kwargs()), + view=self.api_index_view.as_view( + **self.get_api_index_kwargs()), name='api_index' ), ) for model, model_admin in self.registry.iteritems(): model_options = utils.model_options(model) - urlpatterns += patterns('', + urlpatterns += patterns( + '', url('^{}/{}/'.format( model_options.app_label, model_options.object_name.lower()), diff --git a/djadmin2/models.py b/djadmin2/models.py index a34119f8..01a4b44a 100644 --- a/djadmin2/models.py +++ b/djadmin2/models.py @@ -2,13 +2,107 @@ """ Boilerplate for now, will serve a purpose soon! """ from __future__ import division, absolute_import, unicode_literals +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.contrib.admin.util import quote +from django.db import models from django.db.models import signals +from django.utils.encoding import force_text +from django.utils.encoding import smart_text +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext, ugettext_lazy as _ from . import permissions +class LogEntryManager(models.Manager): + def log_action(self, user_id, obj, action_flag, change_message=''): + content_type_id = ContentType.objects.get_for_model(obj).id + e = self.model(None, None, user_id, content_type_id, + smart_text(obj.id), force_text(obj)[:200], + action_flag, change_message) + e.save() + + +@python_2_unicode_compatible +class LogEntry(models.Model): + ADDITION = 1 + CHANGE = 2 + DELETION = 3 + + action_time = models.DateTimeField(_('action time'), auto_now=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name='log_entries') + content_type = models.ForeignKey(ContentType, blank=True, null=True, + related_name='log_entries') + object_id = models.TextField(_('object id'), blank=True, null=True) + object_repr = models.CharField(_('object repr'), max_length=200) + action_flag = models.PositiveSmallIntegerField(_('action flag')) + change_message = models.TextField(_('change message'), blank=True) + + objects = LogEntryManager() + + class Meta: + verbose_name = _('log entry') + verbose_name_plural = _('log entries') + ordering = ('-action_time',) + + def __repr__(self): + return smart_text(self.action_time) + + def __str__(self): + if self.action_flag == self.ADDITION: + return ugettext('Added "%(object)s".') % { + 'object': self.object_repr} + elif self.action_flag == self.CHANGE: + return ugettext('Changed "%(object)s" - %(changes)s') % { + 'object': self.object_repr, + 'changes': self.change_message, + } + elif self.action_flag == self.DELETION: + return ugettext('Deleted "%(object)s."') % { + 'object': self.object_repr} + + return ugettext('LogEntry Object') + + def is_addition(self): + return self.action_flag == self.ADDITION + + def is_change(self): + return self.action_flag == self.CHANGE + + def is_deletion(self): + return self.action_flag == self.DELETION + + @property + def action_type(self): + if self.is_addition(): + return _('added') + if self.is_change(): + return _('changed') + if self.is_deletion(): + return _('deleted') + return '' + + def get_edited_object(self): + "Returns the edited object represented by this log entry" + return self.content_type.get_object_for_this_type(pk=self.object_id) + + def get_admin_url(self): + """ + Returns the admin URL to edit the object represented by this log entry. + This is relative to the Django admin index page. + """ + if self.content_type and self.object_id: + return '{0.app_label}/{0.model}/{1}'.format( + self.content_type, + quote(self.object_id) + ) + return None + # setup signal handlers here, since ``models.py`` will be imported by django # for sure if ``djadmin2`` is listed in the ``INSTALLED_APPS``. -signals.post_syncdb.connect(permissions.create_view_permissions, +signals.post_syncdb.connect( + permissions.create_view_permissions, dispatch_uid="django-admin2.djadmin2.permissions.create_view_permissions") diff --git a/djadmin2/templatetags/admin2_tags.py b/djadmin2/templatetags/admin2_tags.py index 622946c3..833cef0e 100644 --- a/djadmin2/templatetags/admin2_tags.py +++ b/djadmin2/templatetags/admin2_tags.py @@ -7,7 +7,7 @@ from django import template from django.db.models.fields import FieldDoesNotExist -from .. import utils, renderers +from .. import utils, renderers, models register = template.Library() @@ -136,3 +136,10 @@ def render(context, model_instance, attribute_name): # It must be a method instead. field = None return renderer(value, field) + + +@register.inclusion_tag('djadmin2theme_default/includes/history.html', + takes_context=True) +def action_history(context): + actions = models.LogEntry.objects.filter(user__pk=context['user'].pk) + return {'actions': actions} diff --git a/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/includes/history.html b/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/includes/history.html new file mode 100644 index 00000000..c4b9f9bf --- /dev/null +++ b/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/includes/history.html @@ -0,0 +1,20 @@ +{% if actions %} +
    + {% for action in actions %} +
  1. + {% if action.is_addition %} + + {% elif action.is_change %} + + {% else %} + + {% endif %} + {{ action }} + {{ action.content_type.model }} +
  2. + {% endfor %} +
+{% else %} +

None available

+{% endif %} + diff --git a/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/index.html b/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/index.html index 914d4e25..adc1a2bb 100644 --- a/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/index.html +++ b/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/index.html @@ -3,16 +3,16 @@ {% block content %} -
-
+
+
{% for app_label, registry in apps.items %} {% include 'djadmin2theme_default/includes/app_model_list.html' %} {% endfor %}
-
+

{% trans "Recent Actions" %}

{% trans "My Actions" %}
- TODO + {% action_history %}
{% endblock content %} diff --git a/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/model_history.html b/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/model_history.html new file mode 100644 index 00000000..e6534b20 --- /dev/null +++ b/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/model_history.html @@ -0,0 +1,59 @@ +{% extends "djadmin2theme_default/base.html" %} +{% load admin2_tags i18n %} + +{% block title %}{% trans "History for" %} {{ object }}{% endblock title %} + +{% block page_title %}{% trans "History for" %} {{ object }}{% endblock page_title %} + +{% block breadcrumbs %} +
  • + {% trans "Home" %} + / +
  • +
  • + {{ app_label|title }} + / +
  • +
  • + {{ model_name_pluralized|title }} + / +
  • +
  • + {{ object }} + / +
  • +
  • {% trans "History" %}
  • +{% endblock breadcrumbs %} + +{% block content %} + +

    +{% blocktrans with object=object %} + History for {{ object }} +{% endblocktrans %} + +{% if object_list %} + + + + + + + + + + + {% for log in object_list %} + + + + + + + {% endfor %} + +
    {% trans "Date/Time" %}{% trans "User" %}{% trans "Action" %}{% trans "Message" %}
    {{ log.action_time }}{{ log.user }}{{ log.action_type|capfirst }}{{ log.change_message }}
    +{% else %} +

    No history for this object.

    +{% endif %} +{% endblock content %} diff --git a/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/model_update_form.html b/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/model_update_form.html index fc200948..3e303dd1 100644 --- a/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/model_update_form.html +++ b/djadmin2/themes/djadmin2theme_default/templates/djadmin2theme_default/model_update_form.html @@ -9,7 +9,9 @@ {% block page_title %}{% blocktrans with action=action model_name=model_name %}{{ action_name }} {{ model_name }}{% endblocktrans %}{% endblock page_title %} {% block page_title_link %} -History +{% if object.pk %} + History +{% endif %} {% endblock page_title_link %} {% block breadcrumbs %} @@ -41,14 +43,14 @@ {% block content %}
    - + {% if view.model_admin.save_on_top %} {% include "djadmin2theme_default/includes/save_buttons.html" %} {% endif %}
    - +
    {% csrf_token %} {{ form|crispy }} @@ -63,11 +65,11 @@

    {{ formset.model|model_verbose_name_plural|capfirst }}

    - + {% if view.model_admin.save_on_bottom %} {% include "djadmin2theme_default/includes/save_buttons.html" %} {% endif %} - + diff --git a/djadmin2/types.py b/djadmin2/types.py index 1e6e88c3..c77a2d43 100644 --- a/djadmin2/types.py +++ b/djadmin2/types.py @@ -102,6 +102,7 @@ class ModelAdmin2(object): update_view = views.ModelEditFormView detail_view = views.ModelDetailView delete_view = views.ModelDeleteView + history_view = views.ModelHistoryView # API configuration api_serializer_class = None @@ -151,13 +152,15 @@ def get_create_kwargs(self): kwargs = self.get_default_view_kwargs() kwargs.update({ 'inlines': self.inlines, - 'form_class': self.create_form_class if self.create_form_class else self.form_class, + 'form_class': (self.create_form_class if + self.create_form_class else self.form_class), }) return kwargs def get_update_kwargs(self): kwargs = self.get_default_view_kwargs() - form_class = self.update_form_class if self.update_form_class else self.form_class + form_class = (self.update_form_class if + self.update_form_class else self.form_class) if form_class is None: form_class = modelform_factory(self.model) kwargs.update({ @@ -172,8 +175,12 @@ def get_detail_kwargs(self): def get_delete_kwargs(self): return self.get_default_view_kwargs() + def get_history_kwargs(self): + return self.get_default_view_kwargs() + def get_index_url(self): - return reverse('admin2:{}'.format(self.get_prefixed_view_name('index'))) + return reverse('admin2:{}'.format( + self.get_prefixed_view_name('index'))) def get_api_list_kwargs(self): kwargs = self.get_default_api_view_kwargs() @@ -186,7 +193,8 @@ def get_api_detail_kwargs(self): return self.get_default_api_view_kwargs() def get_urls(self): - return patterns('', + return patterns( + '', url( regex=r'^$', view=self.index_view.as_view(**self.get_index_kwargs()), @@ -212,10 +220,16 @@ def get_urls(self): view=self.delete_view.as_view(**self.get_delete_kwargs()), name=self.get_prefixed_view_name('delete') ), + url( + regex=r'^(?P[0-9]+)/history/$', + view=self.history_view.as_view(**self.get_history_kwargs()), + name=self.get_prefixed_view_name('history') + ) ) def get_api_urls(self): - return patterns('', + return patterns( + '', url( regex=r'^$', view=self.api_list_view.as_view(**self.get_api_list_kwargs()), @@ -223,7 +237,8 @@ def get_api_urls(self): ), url( regex=r'^(?P[0-9]+)/$', - view=self.api_detail_view.as_view(**self.get_api_detail_kwargs()), + view=self.api_detail_view.as_view( + **self.get_api_detail_kwargs()), name=self.get_prefixed_view_name('api_detail'), ), ) @@ -244,9 +259,9 @@ def get_list_actions(self): class_actions = getattr(cls, 'list_actions', []) for action in class_actions: actions_dict[action.__name__] = { - 'name': action.__name__, - 'description': actions.get_description(action), - 'action_callable': action + 'name': action.__name__, + 'description': actions.get_description(action), + 'action_callable': action } return actions_dict @@ -270,22 +285,28 @@ def construct_formset(self): class Admin2TabularInline(Admin2Inline): - template = os.path.join(settings.ADMIN2_THEME_DIRECTORY, 'edit_inlines/tabular.html') + template = os.path.join( + settings.ADMIN2_THEME_DIRECTORY, 'edit_inlines/tabular.html') class Admin2StackedInline(Admin2Inline): - template = os.path.join(settings.ADMIN2_THEME_DIRECTORY, 'edit_inlines/stacked.html') + template = os.path.join( + settings.ADMIN2_THEME_DIRECTORY, 'edit_inlines/stacked.html') def immutable_admin_factory(model_admin): - """ Provide an ImmutableAdmin to make it harder for developers to dig themselves into holes. - See https://github.com/twoscoops/django-admin2/issues/99 - Frozen class implementation as namedtuple suggested by Audrey Roy - - Note: This won't stop developers from saving mutable objects to the result, but hopefully - developers attempting that 'workaround/hack' will read our documentation. + """ + Provide an ImmutableAdmin to make it harder for developers to + dig themselves into holes. + See https://github.com/twoscoops/django-admin2/issues/99 + Frozen class implementation as namedtuple suggested by Audrey Roy + + Note: This won't stop developers from saving mutable objects to + the result, but hopefully developers attempting that + 'workaround/hack' will read our documentation. """ ImmutableAdmin = namedtuple('ImmutableAdmin', model_admin.model_admin_attributes, verbose=False) - return ImmutableAdmin(*[getattr(model_admin, x) for x in model_admin.model_admin_attributes]) + return ImmutableAdmin(*[getattr( + model_admin, x) for x in model_admin.model_admin_attributes]) diff --git a/djadmin2/viewmixins.py b/djadmin2/viewmixins.py index fc816a6d..ecc26a7d 100644 --- a/djadmin2/viewmixins.py +++ b/djadmin2/viewmixins.py @@ -8,6 +8,10 @@ from django.core.urlresolvers import reverse, reverse_lazy from django.forms.models import modelform_factory from django.http import HttpResponseRedirect +from django.utils.encoding import force_text +from django.utils.text import get_text_list +from django.utils.translation import ugettext as _ + from braces.views import AccessMixin from . import settings, permissions @@ -43,8 +47,10 @@ def dispatch(self, request, *args, **kwargs): if self.raise_exception: raise PermissionDenied # return a forbidden response else: - return redirect_to_login(request.get_full_path(), - self.get_login_url(), self.get_redirect_field_name()) + return redirect_to_login( + request.get_full_path(), + self.get_login_url(), + self.get_redirect_field_name()) return super(PermissionMixin, self).dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): @@ -67,7 +73,8 @@ class Admin2Mixin(PermissionMixin): index_path = reverse_lazy('admin2:dashboard') def get_template_names(self): - return [os.path.join(settings.ADMIN2_THEME_DIRECTORY, self.default_template_name)] + return [os.path.join( + settings.ADMIN2_THEME_DIRECTORY, self.default_template_name)] def get_model(self): return self.model @@ -139,3 +146,33 @@ def get_success_url(self): # default to index view return reverse(admin2_urlname(self, 'index')) + + def construct_change_message(self, request, form, formsets): + """ Construct a change message from a changed object """ + change_message = [] + if form.changed_data: + change_message.append( + _('Changed {0}.'.format( + get_text_list(form.changed_data, _('and'))))) + + if formsets: + for formset in formsets: + for added_object in formset.new_objects: + change_message.append( + _('Added {0} "{1}".'.format( + force_text(added_object._meta.verbose_name), + force_text(added_object)))) + for changed_object, changed_fields in formset.changed_objects: + change_message.append( + _('Changed {0} for {1} "{2}".'.format( + get_text_list(changed_fields, _('and')), + force_text(changed_object._meta.verbose_name), + force_text(changed_object)))) + for deleted_object in formset.deleted_objects: + change_message.append( + _('Deleted {0} "{1}".'.format( + force_text(deleted_object._meta.verbose_name), + force_text(deleted_object)))) + + change_message = ' '.join(change_message) + return change_message or _('No fields changed.') diff --git a/djadmin2/views.py b/djadmin2/views.py index 1617d054..4a739372 100644 --- a/djadmin2/views.py +++ b/djadmin2/views.py @@ -3,25 +3,28 @@ import operator +from django.contrib.auth import get_user_model from django.contrib.auth.forms import (PasswordChangeForm, AdminPasswordChangeForm) from django.contrib.auth.views import (logout as auth_logout, login as auth_login) -from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse, reverse_lazy -from django.utils.translation import ugettext_lazy from django.db import models +from django.db.models.fields import FieldDoesNotExist from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 from django.utils.encoding import force_text from django.utils.text import capfirst +from django.utils.translation import ugettext_lazy from django.views import generic -from django.db.models.fields import FieldDoesNotExist import extra_views from . import permissions, utils from .forms import AdminAuthenticationForm +from .models import LogEntry from .viewmixins import Admin2Mixin, AdminModel2Mixin, Admin2ModelFormMixin from .filters import build_list_filter @@ -81,8 +84,8 @@ def post(self, request): selected_model_pks = request.POST.getlist('selected_model_pk') queryset = self.model.objects.filter(pk__in=selected_model_pks) - # If action_callable is a class subclassing from actions.BaseListAction - # then we generate the callable object. + # If action_callable is a class subclassing from + # actions.BaseListAction then we generate the callable object. if hasattr(action_callable, "process_queryset"): response = action_callable.as_view(queryset=queryset)(request) else: @@ -130,7 +133,8 @@ def get_queryset(self): search_term = self.request.GET.get('q', None) search_use_distinct = False if self.model_admin.search_fields and search_term: - queryset, search_use_distinct = self.get_search_results(queryset, search_term) + queryset, search_use_distinct = self.get_search_results( + queryset, search_term) if self.model_admin.list_filter: queryset = self.build_list_filter(queryset).qs @@ -185,7 +189,8 @@ def get_context_data(self, **kwargs): return context def get_success_url(self): - view_name = 'admin2:{}_{}_index'.format(self.app_label, self.model_name) + view_name = 'admin2:{}_{}_index'.format( + self.app_label, self.model_name) return reverse(view_name) def get_actions(self): @@ -208,7 +213,8 @@ class ModelDetailView(AdminModel2Mixin, generic.DetailView): permissions.ModelViewPermission) -class ModelEditFormView(AdminModel2Mixin, Admin2ModelFormMixin, extra_views.UpdateWithInlinesView): +class ModelEditFormView(AdminModel2Mixin, Admin2ModelFormMixin, + extra_views.UpdateWithInlinesView): """Context Variables :model: Type of object you are editing @@ -228,8 +234,18 @@ def get_context_data(self, **kwargs): context['action_name'] = ugettext_lazy("Change") return context + def forms_valid(self, form, inlines): + response = super(ModelEditFormView, self).forms_valid(form, inlines) + LogEntry.objects.log_action( + self.request.user.id, + self.object, + LogEntry.CHANGE, + self.construct_change_message(self.request, form, inlines)) + return response + -class ModelAddFormView(AdminModel2Mixin, Admin2ModelFormMixin, extra_views.CreateWithInlinesView): +class ModelAddFormView(AdminModel2Mixin, Admin2ModelFormMixin, + extra_views.CreateWithInlinesView): """Context Variables :model: Type of object you are editing @@ -249,6 +265,15 @@ def get_context_data(self, **kwargs): context['action_name'] = ugettext_lazy("Add") return context + def forms_valid(self, form, inlines): + response = super(ModelAddFormView, self).forms_valid(form, inlines) + LogEntry.objects.log_action( + self.request.user.id, + self.object, + LogEntry.ADDITION, + 'Object created.') + return response + class ModelDeleteView(AdminModel2Mixin, generic.DeleteView): """Context Variables @@ -279,6 +304,38 @@ def _format_callback(obj): }) return context + def delete(self, request, *args, **kwargs): + LogEntry.objects.log_action( + request.user.id, + self.get_object(), + LogEntry.DELETION, + 'Object deleted.') + return super(ModelDeleteView, self).delete(request, *args, **kwargs) + + +class ModelHistoryView(AdminModel2Mixin, generic.ListView): + default_template_name = "model_history.html" + permission_classes = ( + permissions.IsStaffPermission, + permissions.ModelChangePermission + ) + + def get_context_data(self, **kwargs): + context = super(ModelHistoryView, self).get_context_data(**kwargs) + context['model'] = self.get_model() + context['object'] = self.get_object() + return context + + def get_object(self): + return get_object_or_404(self.get_model(), pk=self.kwargs.get('pk')) + + def get_queryset(self): + content_type = ContentType.objects.get_for_model(self.get_object()) + return LogEntry.objects.filter( + content_type=content_type, + object_id=self.get_object().id + ) + class PasswordChangeView(Admin2Mixin, generic.UpdateView):