Merge pull request #349 from brack3t/feature/history

Feature/history - Merging anyway. I'll pull out the auth link tomorrow.
This commit is contained in:
Daniel Greenfeld 2013-07-21 15:42:30 -07:00
commit 5f76d84e72
10 changed files with 357 additions and 50 deletions

View file

@ -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 @@ class Admin2(object):
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 @@ class Admin2(object):
"""
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 @@ class Admin2(object):
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 @@ class Admin2(object):
}
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 @@ class Admin2(object):
name='logout'
),
url(regex=r'^(?P<app_label>\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()),

View file

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

View file

@ -7,7 +7,7 @@ from datetime import date, time, datetime
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}

View file

@ -0,0 +1,20 @@
{% if actions %}
<ol class="unstyled">
{% for action in actions %}
<li>
{% if action.is_addition %}
<i class="added icon-plus"></i>
{% elif action.is_change %}
<i class="changed icon-pencil"></i>
{% else %}
<i class="deleted icon-minus"></i>
{% endif %}
{{ action }}
<span class="muted">{{ action.content_type.model }}</span>
</li>
{% endfor %}
</ol>
{% else %}
<p>None available</p>
{% endif %}

View file

@ -3,16 +3,16 @@
{% block content %}
<div class="row">
<div class="span7">
<div class="row-fluid">
<div class="span8">
{% for app_label, registry in apps.items %}
{% include 'djadmin2theme_default/includes/app_model_list.html' %}
{% endfor %}
</div>
<div class="span5">
<div class="span4 well pull-right">
<h4>{% trans "Recent Actions" %}</h4>
<h5>{% trans "My Actions" %}</h5>
TODO
{% action_history %}
</div>
</div>
{% endblock content %}

View file

@ -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 %}
<li>
<a href="{% url "admin2:dashboard" %}">{% trans "Home" %}</a>
<span class="divider">/</span>
</li>
<li>
<a href="{% url "admin2:app_index" app_label=app_label %}">{{ app_label|title }}</a>
<span class="divider">/</span>
</li>
<li>
<a href="{% url view|admin2_urlname:"index" %}">{{ model_name_pluralized|title }}</a>
<span class="divider">/</span>
</li>
<li>
<a href="{% url view|admin2_urlname:"detail" pk=object.pk %}">{{ object }}</a>
<span class="divider">/</span>
</li>
<li class="active">{% trans "History" %}</li>
{% endblock breadcrumbs %}
{% block content %}
<p>
{% blocktrans with object=object %}
History for {{ object }}
{% endblocktrans %}
{% if object_list %}
<table class="table table-striped table-bordered">
<thead>
<tr>
<th>{% trans "Date/Time" %}</th>
<th>{% trans "User" %}</th>
<th>{% trans "Action" %}</th>
<th>{% trans "Message" %}</th>
</tr>
</thead>
<tbody>
{% for log in object_list %}
<tr>
<td>{{ log.action_time }}</td>
<td>{{ log.user }}</td>
<td>{{ log.action_type|capfirst }}</td>
<td>{{ log.change_message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No history for this object.</p>
{% endif %}
{% endblock content %}

View file

@ -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 %}
<a href="#" class="btn btn-info">History</a>
{% if object.pk %}
<a href="{% url view|admin2_urlname:"history" pk=object.pk %}" class="btn btn-info pull-right">History</a>
{% endif %}
{% endblock page_title_link %}
{% block breadcrumbs %}
@ -41,14 +43,14 @@
{% block content %}
<form method="post">
{% if view.model_admin.save_on_top %}
{% include "djadmin2theme_default/includes/save_buttons.html" %}
{% endif %}
<div class="row-fluid"><!-- begin main form row -->
<div class="span12">
<div class="change_form">
{% csrf_token %}
{{ form|crispy }}
@ -63,11 +65,11 @@
</div>
</div><!-- end main form row -->
{% if view.model_admin.save_on_bottom %}
{% include "djadmin2theme_default/includes/save_buttons.html" %}
{% endif %}
</form>

View file

@ -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 @@ class ModelAdmin2(object):
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 @@ class ModelAdmin2(object):
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 @@ class ModelAdmin2(object):
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 @@ class ModelAdmin2(object):
view=self.delete_view.as_view(**self.get_delete_kwargs()),
name=self.get_prefixed_view_name('delete')
),
url(
regex=r'^(?P<pk>[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 @@ class ModelAdmin2(object):
),
url(
regex=r'^(?P<pk>[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 @@ class ModelAdmin2(object):
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 @@ class Admin2Inline(extra_views.InlineFormSet):
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
"""
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.
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])

View file

@ -8,6 +8,10 @@ from django.core.exceptions import PermissionDenied
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 @@ class PermissionMixin(AccessMixin):
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 @@ class Admin2ModelFormMixin(object):
# 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.')

View file

@ -3,25 +3,28 @@ from __future__ import division, absolute_import, unicode_literals
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 @@ class ModelListView(AdminModel2Mixin, generic.ListView):
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 @@ class ModelListView(AdminModel2Mixin, generic.ListView):
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 @@ class ModelListView(AdminModel2Mixin, generic.ListView):
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 @@ class ModelEditFormView(AdminModel2Mixin, Admin2ModelFormMixin, extra_views.Upda
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 @@ class ModelAddFormView(AdminModel2Mixin, Admin2ModelFormMixin, extra_views.Creat
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 @@ class ModelDeleteView(AdminModel2Mixin, generic.DeleteView):
})
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):