first commit of django-journal 1.0
This commit is contained in:
commit
9c89d98874
|
@ -0,0 +1 @@
|
||||||
|
*.pyc
|
|
@ -0,0 +1,2 @@
|
||||||
|
recursive-include django_journal/locale *.po *.mo
|
||||||
|
recursive-include django_journal/static *.css *.png
|
|
@ -0,0 +1,28 @@
|
||||||
|
Journal application
|
||||||
|
===================
|
||||||
|
|
||||||
|
Log event to a journal. Keep details of the event linked to the event message,
|
||||||
|
keep also the template for displaying the event in case we want to improve
|
||||||
|
display.
|
||||||
|
|
||||||
|
To use just do::
|
||||||
|
|
||||||
|
import django_journal
|
||||||
|
django_journal.record('my-tag', '{user} did this to {that}',
|
||||||
|
user=request.user, that=model_instance)
|
||||||
|
|
||||||
|
|
||||||
|
Admin display
|
||||||
|
-------------
|
||||||
|
|
||||||
|
``admin.JournalModelAdmin`` recompute messages from the journal message as HTML
|
||||||
|
adding links for filtering by object and to the ``change`` admin page for the
|
||||||
|
object if it has one.
|
||||||
|
|
||||||
|
Recording error events
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
If you use transactions you must use ``error_record()`` instead of
|
||||||
|
``record()`` and set ``JOURNAL_DB_FOR_ERROR_ALIAS`` in your settings to
|
||||||
|
define another db alias to use so that journal record does not happen
|
||||||
|
inside the current transaction.
|
|
@ -0,0 +1,53 @@
|
||||||
|
from exceptions import JournalException
|
||||||
|
from models import (Journal, Tag, Template, ObjectData, StringData)
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
__all__ = ('record', 'error_record', 'Journal')
|
||||||
|
|
||||||
|
def unicode_truncate(s, length, encoding='utf-8'):
|
||||||
|
'''Truncate an unicode string so that its UTF-8 encoding is less thant
|
||||||
|
length.'''
|
||||||
|
encoded = s.encode(encoding)[:length]
|
||||||
|
return encoded.decode(encoding, 'ignore')
|
||||||
|
|
||||||
|
def record(tag, tpl, using='default', **kwargs):
|
||||||
|
'''Record an event in the journal. The modification is done inside the
|
||||||
|
current transaction.
|
||||||
|
|
||||||
|
tag:
|
||||||
|
a string identifier giving the type of the event
|
||||||
|
tpl:
|
||||||
|
a format string to describe the event
|
||||||
|
kwargs:
|
||||||
|
a mapping of object or data to interpolate in the format string
|
||||||
|
'''
|
||||||
|
tag, created = Tag.objects.using(using).get_or_create(name=tag)
|
||||||
|
template, created = Template.objects.using(using).get_or_create(content=tpl)
|
||||||
|
try:
|
||||||
|
message = tpl.format(**kwargs)
|
||||||
|
except (KeyError, IndexError), e:
|
||||||
|
raise JournalException(
|
||||||
|
'Missing variable for the template message', tpl, e)
|
||||||
|
journal = Journal.objects.using(using).create(tag=tag, template=template,
|
||||||
|
message=unicode_truncate(message, 128))
|
||||||
|
for tag, value in kwargs.iteritems():
|
||||||
|
tag, created = Tag.objects.using(using).get_or_create(name=tag)
|
||||||
|
if isinstance(value, models.Model):
|
||||||
|
journal.objectdata_set.create(tag=tag, content_object=value)
|
||||||
|
else:
|
||||||
|
journal.stringdata_set.create(tag=tag, content=unicode(value))
|
||||||
|
return journal
|
||||||
|
|
||||||
|
def error_record(tag, tpl, **kwargs):
|
||||||
|
'''Records error events.
|
||||||
|
|
||||||
|
You must use this function when logging error events. It uses another
|
||||||
|
database alias than the default one to be immune to transaction rollback
|
||||||
|
when logging in the middle of a transaction which is going to
|
||||||
|
rollback.
|
||||||
|
'''
|
||||||
|
return record(tag, tpl,
|
||||||
|
using=getattr(settings, 'JOURNAL_DB_FOR_ERROR_ALIAS', 'default'),
|
||||||
|
**kwargs)
|
|
@ -0,0 +1,77 @@
|
||||||
|
import django.contrib.admin as admin
|
||||||
|
|
||||||
|
from models import Journal, Tag, ObjectData, StringData
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||||
|
|
||||||
|
class ObjectDataInlineAdmin(admin.TabularInline):
|
||||||
|
model = ObjectData
|
||||||
|
fields = ('tag', 'content_type', 'content_object')
|
||||||
|
readonly_fields = fields
|
||||||
|
extra = 0
|
||||||
|
max_num = 0
|
||||||
|
|
||||||
|
class StringDataInlineAdmin(admin.TabularInline):
|
||||||
|
model = StringData
|
||||||
|
fields = ('tag', 'content')
|
||||||
|
readonly_fields = fields
|
||||||
|
extra = 0
|
||||||
|
max_num = 0
|
||||||
|
|
||||||
|
class JournalAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('time', 'tag', 'html_message')
|
||||||
|
list_filter = ('tag',)
|
||||||
|
fields = list_display
|
||||||
|
readonly_fields = list_display
|
||||||
|
inlines = (
|
||||||
|
ObjectDataInlineAdmin,
|
||||||
|
StringDataInlineAdmin,
|
||||||
|
)
|
||||||
|
date_hierarchy = 'time'
|
||||||
|
search_fields = ('message','tag__name','time')
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
'all': ('journal/css/journal.css',),
|
||||||
|
}
|
||||||
|
|
||||||
|
def queryset(self, request):
|
||||||
|
'''Get as much data as possible using the fewest requests possible.'''
|
||||||
|
qs = super(JournalAdmin, self).queryset(request)
|
||||||
|
qs = qs.select_related('tag', 'template') \
|
||||||
|
.prefetch_related('objectdata_set__content_type', 'stringdata_set',
|
||||||
|
'objectdata_set__tag')
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def lookup_allowed(self, key, *args, **kwargs):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def object_link(self, obj_data):
|
||||||
|
url = '{0}:{1}_{2}_change'.format(self.admin_site.name,
|
||||||
|
obj_data.content_type.app_label,
|
||||||
|
obj_data.content_type.model)
|
||||||
|
#try:
|
||||||
|
url = reverse(url, args=(obj_data.object_id,))
|
||||||
|
#except NoReverseMatch:
|
||||||
|
# return ''
|
||||||
|
return '<a href="%s" class="external-link"></a>' % url
|
||||||
|
|
||||||
|
def html_message(self, entry):
|
||||||
|
import pdb
|
||||||
|
pdb.set_trace()
|
||||||
|
ctx = entry.message_context()
|
||||||
|
for obj_data in entry.objectdata_set.select_related():
|
||||||
|
content_type_id = obj_data.content_type.pk;
|
||||||
|
object_id = obj_data.object_id;
|
||||||
|
key = obj_data.tag.name;
|
||||||
|
ctx[key] = '<a href="?objectdata__content_type={0}&objectdata__object_id={1}">{2}{3}</a>'.format(
|
||||||
|
content_type_id, object_id, ctx[key], self.object_link(obj_data))
|
||||||
|
template = _(entry.template_content) # localize the template
|
||||||
|
return '<span>{}</span>'.format(template.format(**ctx))
|
||||||
|
html_message.allow_tags = True
|
||||||
|
html_message.short_description = _('Message')
|
||||||
|
html_message.admin_order_field = 'message'
|
||||||
|
|
||||||
|
admin.site.register(Journal, JournalAdmin)
|
||||||
|
admin.site.register(Tag)
|
|
@ -0,0 +1,2 @@
|
||||||
|
class JournalException(RuntimeError):
|
||||||
|
pass
|
Binary file not shown.
|
@ -0,0 +1,41 @@
|
||||||
|
# Message translation for django-journal
|
||||||
|
# Copyright (C) 2012 Entr'ouvert
|
||||||
|
# This file is distributed under the same license as the django_journal package.
|
||||||
|
# Benjamin Dauvergne <bdauvergne@entrouvert.com>, 2012
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: django_journal 1.0\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2012-12-10 12:47+0100\n"
|
||||||
|
"PO-Revision-Date: 2012-12-10 12:51+0100\n"
|
||||||
|
"Last-Translator: Benjamin Dauvergne <bdauvergne@entrouvert.com>\n"
|
||||||
|
"Language: \n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=2; plural=(n > 1)\n"
|
||||||
|
|
||||||
|
#: admin.py:62
|
||||||
|
msgid "Message"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: models.py:30
|
||||||
|
msgid "Journal tag"
|
||||||
|
msgstr "Étiquette"
|
||||||
|
|
||||||
|
#: models.py:101
|
||||||
|
msgid "Journal entry"
|
||||||
|
msgstr "Entrée du journal"
|
||||||
|
|
||||||
|
#: models.py:102
|
||||||
|
msgid "Journal entries"
|
||||||
|
msgstr "Entrées du journal"
|
||||||
|
|
||||||
|
#: models.py:143
|
||||||
|
msgid "Linked text string"
|
||||||
|
msgstr "Texte lié"
|
||||||
|
|
||||||
|
#: models.py:165
|
||||||
|
msgid "Linked object"
|
||||||
|
msgstr "Objet lié"
|
|
@ -0,0 +1,168 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.contrib.contenttypes import generic
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class TagManager(models.Manager):
|
||||||
|
def get_by_natural_key(self, name):
|
||||||
|
return self.get(name=name)
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(models.Model):
|
||||||
|
'''Tag allows typing event and data linked to events.
|
||||||
|
|
||||||
|
name:
|
||||||
|
the string identifier of the tag
|
||||||
|
'''
|
||||||
|
objects = TagManager()
|
||||||
|
name = models.CharField(max_length=32, unique=True, db_index=True)
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def natural_key(self):
|
||||||
|
return (self.name,)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('name',)
|
||||||
|
verbose_name = _('Journal tag')
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateManager(models.Manager):
|
||||||
|
def get_by_natural_key(self, content):
|
||||||
|
return self.get(content=content)
|
||||||
|
|
||||||
|
|
||||||
|
class Template(models.Model):
|
||||||
|
'''Template for formatting an event.
|
||||||
|
|
||||||
|
ex.: Template(
|
||||||
|
content='{user1} gave group {group} to {user2}')
|
||||||
|
'''
|
||||||
|
objects = TemplateManager()
|
||||||
|
content = models.TextField(unique=True, db_index=True)
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def natural_key(self):
|
||||||
|
return (self.content,)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('content',)
|
||||||
|
|
||||||
|
|
||||||
|
class JournalManager(models.Manager):
|
||||||
|
def for_object(self, obj):
|
||||||
|
'''Return Journal records linked to this object.'''
|
||||||
|
content_type = ContentType.objects.get_for_model(obj)
|
||||||
|
return self.filter(journalobjectdata__content_type=content_type,
|
||||||
|
journalobjectdata__object_id=obj.pk)
|
||||||
|
|
||||||
|
def for_tag(self, tag):
|
||||||
|
'''Returns Journal records linked to this tag by their own tag or
|
||||||
|
the tag on their data records.
|
||||||
|
'''
|
||||||
|
if not isinstance(tag, Tag):
|
||||||
|
try:
|
||||||
|
tag = Tag.objects.get(name=tag)
|
||||||
|
except Tag.DoesNotExist:
|
||||||
|
return self.none()
|
||||||
|
# always remember: multiple join (OR in WHERE) produces duplicate
|
||||||
|
# lines ! Use .distinct() for safety.
|
||||||
|
return self.filter(models.Q(tag=tag)|
|
||||||
|
models.Q(objectdata__tag=tag)|
|
||||||
|
models.Q(stringdata__tag=tag)) \
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
|
||||||
|
class Journal(models.Model):
|
||||||
|
'''One line of the journal.
|
||||||
|
|
||||||
|
Each recorded event in the journal is a Journal instance.
|
||||||
|
|
||||||
|
time - the time at which the event was recorded
|
||||||
|
tag - the tag giving the type of event
|
||||||
|
template - a format string to present the event
|
||||||
|
message - a simple string representation of the event, computed using
|
||||||
|
the template and associated datas.
|
||||||
|
'''
|
||||||
|
objects = JournalManager()
|
||||||
|
|
||||||
|
time = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
|
tag = models.ForeignKey(Tag, on_delete=models.PROTECT)
|
||||||
|
template = models.ForeignKey(Template, on_delete=models.PROTECT)
|
||||||
|
message = models.CharField(max_length=128, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('time', 'id')
|
||||||
|
verbose_name = _('Journal entry')
|
||||||
|
verbose_name_plural = _('Journal entries')
|
||||||
|
|
||||||
|
def message_context(self):
|
||||||
|
ctx = {}
|
||||||
|
for data in self.objectdata_set.all():
|
||||||
|
try:
|
||||||
|
ctx[data.tag.name] = unicode(data.content_object)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
ctx[data.tag.name] = '<deleted>'
|
||||||
|
for data in self.stringdata_set.all():
|
||||||
|
ctx[data.tag.name] = data.content
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
if len(self.message) < 125:
|
||||||
|
return self.message
|
||||||
|
ctx = self.message_context()
|
||||||
|
return self.template.content.format(**ctx)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<Journal pk:{0} tag:{1} message:{2}>'.format(
|
||||||
|
self.pk, unicode(self.tag).encode('utf-8'),
|
||||||
|
unicode(self.message).encode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
class StringData(models.Model):
|
||||||
|
'''String data associated to a recorded event.
|
||||||
|
|
||||||
|
journal:
|
||||||
|
the recorded event
|
||||||
|
tag:
|
||||||
|
the identifier for this data
|
||||||
|
content:
|
||||||
|
the string value of the data
|
||||||
|
'''
|
||||||
|
journal = models.ForeignKey(Journal)
|
||||||
|
tag = models.ForeignKey(Tag)
|
||||||
|
content = models.TextField(db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = (('journal', 'tag'),)
|
||||||
|
verbose_name = _('Linked text string')
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectData(models.Model):
|
||||||
|
'''Object data associated with a recorded event.
|
||||||
|
|
||||||
|
journal:
|
||||||
|
the recorded event
|
||||||
|
tag:
|
||||||
|
the identifier for this data
|
||||||
|
content_object:
|
||||||
|
the object value of the data
|
||||||
|
'''
|
||||||
|
journal = models.ForeignKey(Journal)
|
||||||
|
tag = models.ForeignKey(Tag)
|
||||||
|
content_type = models.ForeignKey(ContentType)
|
||||||
|
object_id = models.PositiveIntegerField(db_index=True)
|
||||||
|
content_object = generic.GenericForeignKey('content_type',
|
||||||
|
'object_id')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = (('journal', 'tag'),)
|
||||||
|
verbose_name = _('Linked object')
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return u'{}:{}:{}'.format(self.journal.id, self.tag, self.content_object)
|
|
@ -0,0 +1,4 @@
|
||||||
|
.external-link {
|
||||||
|
background: url('../images/external-link-ltr-icon.png') center right no-repeat;
|
||||||
|
padding-right: 13px
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 180 B |
|
@ -0,0 +1,41 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.contrib.auth.models import User, Group
|
||||||
|
|
||||||
|
from . import record
|
||||||
|
import models
|
||||||
|
|
||||||
|
class JournalTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.users = []
|
||||||
|
self.groups = []
|
||||||
|
for i in range(20):
|
||||||
|
self.users.append(
|
||||||
|
User.objects.create(username='user%s' % i))
|
||||||
|
for i in range(20):
|
||||||
|
self.groups.append(
|
||||||
|
Group.objects.create(name='group%s' % i))
|
||||||
|
for i in range(20):
|
||||||
|
record('login', '{user} logged in', user=self.users[i])
|
||||||
|
for i in range(20):
|
||||||
|
record('group-changed', '{user1} gave group {group} to {user2}',
|
||||||
|
user1=self.users[i], group=self.groups[i],
|
||||||
|
user2=self.users[(i+1) % 20])
|
||||||
|
for i in range(20):
|
||||||
|
record('logout', '{user} logged out', user=self.users[i])
|
||||||
|
|
||||||
|
def test_count(self):
|
||||||
|
self.assertEqual(models.Journal.objects.count(), 60)
|
||||||
|
|
||||||
|
def test_login(self):
|
||||||
|
for i, event in zip(range(20), models.Journal.objects.for_tag('login')):
|
||||||
|
self.assertEqual(unicode(event), 'user{0} logged in'.format(i))
|
||||||
|
|
||||||
|
def test_groups(self):
|
||||||
|
for i, event in zip(range(40), models.Journal.objects.for_tag('group-changed')):
|
||||||
|
self.assertEqual(unicode(event),
|
||||||
|
'user{0} gave group group{0} to user{1}'.format(i, (i+1)%20))
|
||||||
|
|
||||||
|
def test_logout(self):
|
||||||
|
for i, event in zip(range(20), models.Journal.objects.for_tag('logout')):
|
||||||
|
self.assertEqual(unicode(event), 'user{0} logged out'.format(i))
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
#!/usr/bin/python
|
||||||
|
from setuptools import setup, find_packages
|
||||||
|
import os
|
||||||
|
|
||||||
|
setup(name='django-journal',
|
||||||
|
version='1.0',
|
||||||
|
license='AGPLv3',
|
||||||
|
description='Keep a structured -- i.e. not just log strings -- journal'
|
||||||
|
' of events in your applications',
|
||||||
|
url='http://dev.entrouvert.org/projects/django-journal/',
|
||||||
|
download_url='http://repos.entrouvert.org/django-journal.git/',
|
||||||
|
author="Entr'ouvert",
|
||||||
|
author_email="info@entrouvert.com",
|
||||||
|
packages=find_packages(os.path.dirname(__file__) or '.'),
|
||||||
|
install_requires=[
|
||||||
|
'django >= 1.4.2, < 1.5',
|
||||||
|
],
|
||||||
|
)
|
Loading…
Reference in New Issue