first commit of django-journal 1.0

This commit is contained in:
Benjamin Dauvergne 2012-12-10 14:12:57 +01:00
commit 9c89d98874
13 changed files with 435 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.pyc

2
MANIFEST.in Normal file
View File

@ -0,0 +1,2 @@
recursive-include django_journal/locale *.po *.mo
recursive-include django_journal/static *.css *.png

28
README.rst Normal file
View File

@ -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.

View File

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

77
django_journal/admin.py Normal file
View File

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

View File

@ -0,0 +1,2 @@
class JournalException(RuntimeError):
pass

Binary file not shown.

View File

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

168
django_journal/models.py Normal file
View File

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

View File

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

41
django_journal/tests.py Normal file
View File

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

18
setup.py Executable file
View File

@ -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',
],
)