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