From 9c89d98874b44c0359a227b8f919a0375b181bc4 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Mon, 10 Dec 2012 14:12:57 +0100 Subject: [PATCH] first commit of django-journal 1.0 --- .gitignore | 1 + MANIFEST.in | 2 + README.rst | 28 +++ django_journal/__init__.py | 53 ++++++ django_journal/admin.py | 77 ++++++++ django_journal/exceptions.py | 2 + .../locale/fr/LC_MESSAGES/django.mo | Bin 0 -> 666 bytes .../locale/fr/LC_MESSAGES/django.po | 41 +++++ django_journal/models.py | 168 ++++++++++++++++++ django_journal/static/journal/css/journal.css | 4 + .../journal/images/external-link-ltr-icon.png | Bin 0 -> 180 bytes django_journal/tests.py | 41 +++++ setup.py | 18 ++ 13 files changed, 435 insertions(+) create mode 100644 .gitignore create mode 100644 MANIFEST.in create mode 100644 README.rst create mode 100644 django_journal/__init__.py create mode 100644 django_journal/admin.py create mode 100644 django_journal/exceptions.py create mode 100644 django_journal/locale/fr/LC_MESSAGES/django.mo create mode 100644 django_journal/locale/fr/LC_MESSAGES/django.po create mode 100644 django_journal/models.py create mode 100644 django_journal/static/journal/css/journal.css create mode 100644 django_journal/static/journal/images/external-link-ltr-icon.png create mode 100644 django_journal/tests.py create mode 100755 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..6b67d48 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include django_journal/locale *.po *.mo +recursive-include django_journal/static *.css *.png diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..e6c48e2 --- /dev/null +++ b/README.rst @@ -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. diff --git a/django_journal/__init__.py b/django_journal/__init__.py new file mode 100644 index 0000000..eeeeae4 --- /dev/null +++ b/django_journal/__init__.py @@ -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) diff --git a/django_journal/admin.py b/django_journal/admin.py new file mode 100644 index 0000000..0311e91 --- /dev/null +++ b/django_journal/admin.py @@ -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 '' % 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] = '{2}{3}'.format( + content_type_id, object_id, ctx[key], self.object_link(obj_data)) + template = _(entry.template_content) # localize the template + return '{}'.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) diff --git a/django_journal/exceptions.py b/django_journal/exceptions.py new file mode 100644 index 0000000..8544bcb --- /dev/null +++ b/django_journal/exceptions.py @@ -0,0 +1,2 @@ +class JournalException(RuntimeError): + pass diff --git a/django_journal/locale/fr/LC_MESSAGES/django.mo b/django_journal/locale/fr/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..0341acc14530eee712cb22f918a5da10bf8714a3 GIT binary patch literal 666 zcmZvZ%}(1u5XYC6_TUO3(Q6NLY=xHEQL7@;pb7{QC=pR)daJ5UJh+=WyXo#25vj*s zIC0_~$Rlvhp%2h|CElSA&{^UDl{)fI^YveQcKmy7`BJbwWnM6C<{9&y33kP-GE-)S zxn@HDXPy6IzsB4!pO%F9!~Qwbdo-_~u#Y&O)c!~9FW9sE&Xzgil6b;|SspY0*rBbQ zQ6-=ur_qat`Q$DmRfx7WUoi*UAL4)(8Q~d$=cA?&U1vi}9^~>PIAz*$&N2{E_bTJ%gm+_b&oQ+>rfG&lY5FVg+`n-WO!mo8tI#N>v0mtER;Pw z)#3gB)xAyTy0-EpGpCF%6@qjoF{{o&5w9Wtk literal 0 HcmV?d00001 diff --git a/django_journal/locale/fr/LC_MESSAGES/django.po b/django_journal/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..972c150 --- /dev/null +++ b/django_journal/locale/fr/LC_MESSAGES/django.po @@ -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 , 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 \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é" diff --git a/django_journal/models.py b/django_journal/models.py new file mode 100644 index 0000000..9a038ef --- /dev/null +++ b/django_journal/models.py @@ -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] = '' + 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 ''.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) diff --git a/django_journal/static/journal/css/journal.css b/django_journal/static/journal/css/journal.css new file mode 100644 index 0000000..80a53e9 --- /dev/null +++ b/django_journal/static/journal/css/journal.css @@ -0,0 +1,4 @@ +.external-link { + background: url('../images/external-link-ltr-icon.png') center right no-repeat; + padding-right: 13px +} diff --git a/django_journal/static/journal/images/external-link-ltr-icon.png b/django_journal/static/journal/images/external-link-ltr-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fe642b5ce17451f84056ca553320edb97386b319 GIT binary patch literal 180 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4NtU=qlmzFem6RtIr7}3CSU5V94PdSpLfI-4nl?n&nd*J}{}*aJ_ZT zm>3f}`6l~Z#^1U9i8(gSEmt4(P4Qk4nepIkTjQTjIf+*vgTe~DWM4fp!z_D literal 0 HcmV?d00001 diff --git a/django_journal/tests.py b/django_journal/tests.py new file mode 100644 index 0000000..c3636f1 --- /dev/null +++ b/django_journal/tests.py @@ -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)) + diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..602261a --- /dev/null +++ b/setup.py @@ -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', + ], +)