0001-misc-apply-black-54260.patch
django_journal/actions.py | ||
---|---|---|
8 | 8 | |
9 | 9 |
from . import models |
10 | 10 | |
11 | ||
11 | 12 |
def export_as_csv_generator(queryset): |
12 | 13 |
header = ['time', 'tag', 'message'] |
13 | 14 |
tags = set(models.Tag.objects.filter(objectdata__journal__in=queryset).values_list('name', flat=True)) |
... | ... | |
15 | 16 |
tags.add('%s__id' % tag) |
16 | 17 |
tags |= set(models.Tag.objects.filter(stringdata__journal__in=queryset).values_list('name', flat=True)) |
17 | 18 |
extra_headers = list(sorted(tags)) |
18 |
yield header+extra_headers
|
|
19 |
yield header + extra_headers
|
|
19 | 20 |
for journal in queryset: |
20 | 21 |
row = { |
21 |
'time': journal.time.isoformat(' '),
|
|
22 |
'tag': force_text(journal.tag.name),
|
|
23 |
'message': force_text(journal),
|
|
24 |
}
|
|
22 |
'time': journal.time.isoformat(' '), |
|
23 |
'tag': force_text(journal.tag.name), |
|
24 |
'message': force_text(journal), |
|
25 |
} |
|
25 | 26 |
for stringdata in journal.stringdata_set.all(): |
26 | 27 |
row_name = stringdata.tag.name.encode('utf-8') |
27 | 28 |
row[force_text(row_name)] = force_text(stringdata.content) |
... | ... | |
34 | 35 |
row[row_name] = force_text(objectdata.content_object) |
35 | 36 |
yield row |
36 | 37 | |
38 | ||
37 | 39 |
def export_as_csv(modeladmin, request, queryset): |
38 | 40 |
""" |
39 | 41 |
CSV export for journal |
... | ... | |
47 | 49 |
for row in l: |
48 | 50 |
writer.writerow(row) |
49 | 51 |
return response |
52 | ||
53 | ||
50 | 54 |
export_as_csv.short_description = _(u"Export CSV file") |
django_journal/admin.py | ||
---|---|---|
13 | 13 | |
14 | 14 | |
15 | 15 |
class ModelAdminFormatter(Formatter): |
16 |
def __init__(self, model_admin=None, filter_link=True, |
|
17 |
object_link=True): |
|
16 |
def __init__(self, model_admin=None, filter_link=True, object_link=True): |
|
18 | 17 |
self.filter_link = filter_link |
19 | 18 |
self.object_link = object_link |
20 | 19 |
self.model_admin = model_admin |
... | ... | |
22 | 21 | |
23 | 22 |
def build_object_link(self, value): |
24 | 23 |
content_type = ContentType.objects.get_for_model(value.__class__) |
25 |
url = u'{0}:{1}_{2}_change'.format(self.model_admin.admin_site.name, |
|
26 |
content_type.app_label, content_type.model) |
|
24 |
url = u'{0}:{1}_{2}_change'.format( |
|
25 |
self.model_admin.admin_site.name, content_type.app_label, content_type.model |
|
26 |
) |
|
27 | 27 |
try: |
28 | 28 |
url = reverse(url, args=(value.pk,)) |
29 | 29 |
except NoReverseMatch: |
... | ... | |
36 | 36 |
if self.filter_link: |
37 | 37 |
content_type = ContentType.objects.get_for_model(value.__class__) |
38 | 38 |
res = u'<a href="?objectdata__content_type={0}&objectdata__object_id={1}">{2}</a>'.format( |
39 |
content_type.id, value.pk, escape(force_text(value))) |
|
39 |
content_type.id, value.pk, escape(force_text(value)) |
|
40 |
) |
|
40 | 41 |
else: |
41 | 42 |
res = escape(force_text(value)) |
42 | 43 |
if self.object_link: |
... | ... | |
52 | 53 |
extra = 0 |
53 | 54 |
max_num = 0 |
54 | 55 | |
56 | ||
55 | 57 |
class StringDataInlineAdmin(admin.TabularInline): |
56 | 58 |
model = StringData |
57 | 59 |
fields = ('tag', 'content') |
... | ... | |
59 | 61 |
extra = 0 |
60 | 62 |
max_num = 0 |
61 | 63 | |
64 | ||
62 | 65 |
class JournalAdmin(admin.ModelAdmin): |
63 | 66 |
list_display = ('time', '_tag', 'user', 'ip', 'message_for_list') |
64 | 67 |
list_filter = ('tag',) |
65 | 68 |
fields = ('time', 'tag', 'user', 'ip', 'message_for_change') |
66 | 69 |
readonly_fields = fields |
67 | 70 |
inlines = ( |
68 |
ObjectDataInlineAdmin,
|
|
69 |
StringDataInlineAdmin,
|
|
71 |
ObjectDataInlineAdmin,
|
|
72 |
StringDataInlineAdmin, |
|
70 | 73 |
) |
71 | 74 |
date_hierarchy = 'time' |
72 |
search_fields = ('message','tag__name','time')
|
|
73 |
actions = [ export_as_csv ]
|
|
75 |
search_fields = ('message', 'tag__name', 'time')
|
|
76 |
actions = [export_as_csv]
|
|
74 | 77 | |
75 | 78 |
class Media: |
76 | 79 |
css = { |
77 |
'all': ('journal/css/journal.css',),
|
|
80 |
'all': ('journal/css/journal.css',), |
|
78 | 81 |
} |
79 | 82 | |
80 | 83 |
def queryset(self, request): |
81 | 84 |
'''Get as much data as possible using the fewest requests possible.''' |
82 | 85 |
qs = super(JournalAdmin, self).queryset(request) |
83 |
qs = qs.select_related('tag', 'template') \ |
|
84 |
.prefetch_related('objectdata_set__content_type', |
|
85 |
'stringdata_set', 'objectdata_set__tag', |
|
86 |
'stringdata_set__tag', 'objectdata_set__content_object') |
|
86 |
qs = qs.select_related('tag', 'template').prefetch_related( |
|
87 |
'objectdata_set__content_type', |
|
88 |
'stringdata_set', |
|
89 |
'objectdata_set__tag', |
|
90 |
'stringdata_set__tag', |
|
91 |
'objectdata_set__content_object', |
|
92 |
) |
|
87 | 93 |
return qs |
88 | 94 | |
89 | 95 |
def lookup_allowed(self, key, *args, **kwargs): |
... | ... | |
91 | 97 | |
92 | 98 |
def _tag(self, entry): |
93 | 99 |
name = entry.tag.name.replace(u'-', u'\u2011') |
94 |
res = format_html('<a href="?tag__id__exact={0}">{1}</a>', |
|
95 |
escape(entry.tag.id), escape(name)) |
|
100 |
res = format_html('<a href="?tag__id__exact={0}">{1}</a>', escape(entry.tag.id), escape(name)) |
|
96 | 101 |
return res |
102 | ||
97 | 103 |
_tag.short_description = _('tag') |
98 | 104 | |
99 | 105 |
def ip(self, entry): |
100 | 106 |
'''Search and return any associated stringdata whose tag is "ip"''' |
101 | 107 |
for stringdata in entry.stringdata_set.all(): |
102 | 108 |
if stringdata.tag.name == 'ip': |
103 |
return format_html('<a href="?stringdata__tag__id={tag_id}&' \ |
|
104 |
'stringdata__content={ip}">{ip}</a>', |
|
105 |
tag_id=stringdata.tag.id, ip=stringdata.content) |
|
109 |
return format_html( |
|
110 |
'<a href="?stringdata__tag__id={tag_id}&' 'stringdata__content={ip}">{ip}</a>', |
|
111 |
tag_id=stringdata.tag.id, |
|
112 |
ip=stringdata.content, |
|
113 |
) |
|
106 | 114 |
return _('None') |
115 | ||
107 | 116 |
ip.short_description = _('IP') |
108 | 117 | |
109 | 118 |
def user(self, entry): |
110 | 119 |
'''Search and return any associated objectdata whose tag is "user"''' |
111 | 120 |
for objectdata in entry.objectdata_set.all(): |
112 | 121 |
if objectdata.tag.name == 'user': |
113 |
return format_html(self.object_filter_link(objectdata) + \ |
|
114 |
self.object_link(objectdata)) |
|
122 |
return format_html(self.object_filter_link(objectdata) + self.object_link(objectdata)) |
|
115 | 123 |
return _('None') |
124 | ||
116 | 125 |
user.short_description = _('User') |
117 | 126 | |
118 | 127 |
def object_filter_link(self, objectdata): |
... | ... | |
120 | 129 |
caption = force_text(objectdata.content_object) |
121 | 130 |
else: |
122 | 131 |
caption = _(u'<deleted {content_type} {object_id}>').format( |
123 |
content_type=objectdata.content_type,
|
|
124 |
object_id=objectdata.object_id)
|
|
132 |
content_type=objectdata.content_type, object_id=objectdata.object_id
|
|
133 |
) |
|
125 | 134 |
return u'<a href="?objectdata__content_type={0}&objectdata__object_id={1}">{2}</a>'.format( |
126 |
objectdata.content_type_id, |
|
127 |
objectdata.object_id, |
|
128 |
escape(caption)) |
|
135 |
objectdata.content_type_id, objectdata.object_id, escape(caption) |
|
136 |
) |
|
129 | 137 | |
130 | 138 |
def object_link(self, obj_data): |
131 | 139 |
if obj_data.content_object is None: |
132 | 140 |
return u'' |
133 |
url = u'{0}:{1}_{2}_change'.format(self.admin_site.name,
|
|
134 |
obj_data.content_type.app_label,
|
|
135 |
obj_data.content_type.model)
|
|
141 |
url = u'{0}:{1}_{2}_change'.format( |
|
142 |
self.admin_site.name, obj_data.content_type.app_label, obj_data.content_type.model
|
|
143 |
) |
|
136 | 144 |
try: |
137 | 145 |
url = reverse(url, args=(obj_data.object_id,)) |
138 | 146 |
except NoReverseMatch: |
... | ... | |
144 | 152 |
formatter = ModelAdminFormatter(model_admin=self, filter_link=False) |
145 | 153 |
message = formatter.format(escape(entry.template.content), **ctx) |
146 | 154 |
return format_html('<span>{}</span>', mark_safe(message)) |
155 | ||
147 | 156 |
message_for_change.short_description = _('Message') |
148 | 157 | |
149 | 158 |
def message_for_list(self, entry): |
... | ... | |
151 | 160 |
formatter = ModelAdminFormatter(model_admin=self) |
152 | 161 |
message = formatter.format(entry.template.content, **ctx) |
153 | 162 |
return format_html('<span>{}</span>', mark_safe(message)) |
163 | ||
154 | 164 |
message_for_list.short_description = _('Message') |
155 | 165 |
message_for_list.admin_order_field = 'message' |
156 | 166 | |
167 | ||
157 | 168 |
admin.site.register(Journal, JournalAdmin) |
158 | 169 |
admin.site.register(Tag) |
django_journal/decorator.py | ||
---|---|---|
4 | 4 |
if hasattr(transaction, 'atomic'): |
5 | 5 |
atomic = transaction.atomic |
6 | 6 |
else: |
7 | ||
7 | 8 |
class Transaction(object): |
8 | 9 |
sid = None |
9 | 10 | |
... | ... | |
41 | 42 |
def wrapper(*args, **kwargs): |
42 | 43 |
with self.__class__(using=self.using): |
43 | 44 |
return func(*args, **kwargs) |
44 |
return wrapper |
|
45 | 45 | |
46 |
return wrapper |
|
46 | 47 | |
47 | 48 |
def atomic(using=None): |
48 | 49 |
""" |
... | ... | |
56 | 57 |
if callable(using): |
57 | 58 |
return Transaction(DEFAULT_DB_ALIAS)(using) |
58 | 59 |
return Transaction(using) |
59 | ||
60 |
django_journal/journal.py | ||
---|---|---|
11 | 11 | |
12 | 12 | |
13 | 13 |
def unicode_truncate(s, length, encoding='utf-8'): |
14 |
'''Truncate an unicode string so that its UTF-8 encoding is less than
|
|
15 |
length.'''
|
|
14 |
"""Truncate an unicode string so that its UTF-8 encoding is less than
|
|
15 |
length."""
|
|
16 | 16 |
encoded = s.encode(encoding)[:length] |
17 | 17 |
return encoded.decode(encoding, 'ignore') |
18 | 18 | |
19 | 19 | |
20 | 20 |
@atomic |
21 | 21 |
def record(tag, template, using=None, **kwargs): |
22 |
'''Record an event in the journal. The modification is done inside the
|
|
23 |
current transaction.
|
|
22 |
"""Record an event in the journal. The modification is done inside the
|
|
23 |
current transaction. |
|
24 | 24 | |
25 |
tag:
|
|
26 |
a string identifier giving the type of the event
|
|
27 |
tpl:
|
|
28 |
a format string to describe the event
|
|
29 |
kwargs:
|
|
30 |
a mapping of object or data to interpolate in the format string
|
|
31 |
'''
|
|
25 |
tag: |
|
26 |
a string identifier giving the type of the event |
|
27 |
tpl: |
|
28 |
a format string to describe the event |
|
29 |
kwargs: |
|
30 |
a mapping of object or data to interpolate in the format string |
|
31 |
"""
|
|
32 | 32 |
template = force_text(template) |
33 | 33 |
tag = Tag.objects.using(using).get_cached(name=tag) |
34 | 34 |
template = Template.objects.using(using).get_cached(content=template) |
35 | 35 |
try: |
36 | 36 |
message = template.content.format(**kwargs) |
37 | 37 |
except (KeyError, IndexError) as e: |
38 |
raise JournalException( |
|
39 |
'Missing variable for the template message', template, e) |
|
38 |
raise JournalException('Missing variable for the template message', template, e) |
|
40 | 39 |
try: |
41 | 40 |
logger = logging.getLogger('django.journal.%s' % tag) |
42 | 41 |
if tag.name == 'error' or tag.name.startswith('error-'): |
... | ... | |
49 | 48 |
try: |
50 | 49 |
logging.getLogger('django.journal').exception('Unable to log msg') |
51 | 50 |
except: |
52 |
pass # we tried, really, we tried |
|
53 |
journal = Journal.objects.using(using).create(tag=tag, template=template, |
|
54 |
message=unicode_truncate(message, 128)) |
|
51 |
pass # we tried, really, we tried |
|
52 |
journal = Journal.objects.using(using).create( |
|
53 |
tag=tag, template=template, message=unicode_truncate(message, 128) |
|
54 |
) |
|
55 | 55 |
for name, value in kwargs.items(): |
56 | 56 |
if value is None: |
57 | 57 |
continue |
58 | 58 |
tag = Tag.objects.using(using).get_cached(name=name) |
59 | 59 |
if isinstance(value, django.db.models.Model): |
60 | 60 |
journal.objectdata_set.create( |
61 |
tag=tag, content_type=ContentType.objects.db_manager(using).get_for_model(value), |
|
62 |
object_id=value.pk |
|
61 |
tag=tag, |
|
62 |
content_type=ContentType.objects.db_manager(using).get_for_model(value), |
|
63 |
object_id=value.pk, |
|
63 | 64 |
) |
64 | 65 |
else: |
65 | 66 |
journal.stringdata_set.create(tag=tag, content=force_text(value)) |
... | ... | |
67 | 68 | |
68 | 69 | |
69 | 70 |
def error_record(tag, tpl, **kwargs): |
70 |
'''Records error events.
|
|
71 |
"""Records error events.
|
|
71 | 72 | |
72 |
You must use this function when logging error events. It uses another
|
|
73 |
database alias than the default one to be immune to transaction rollback
|
|
74 |
when logging in the middle of a transaction which is going to
|
|
75 |
rollback.
|
|
76 |
'''
|
|
73 |
You must use this function when logging error events. It uses another |
|
74 |
database alias than the default one to be immune to transaction rollback |
|
75 |
when logging in the middle of a transaction which is going to |
|
76 |
rollback. |
|
77 |
"""
|
|
77 | 78 |
if kwargs.get('using') is None: |
78 | 79 |
kwargs['using'] = getattr(settings, 'JOURNAL_DB_FOR_ERROR_ALIAS', 'default') |
79 | 80 |
django_journal/managers.py | ||
---|---|---|
27 | 27 |
'''Return Journal records linked to this object.''' |
28 | 28 |
content_type = ContentType.objects.get_for_model(obj) |
29 | 29 |
if tag is None: |
30 |
return self.filter(objectdata__content_type=content_type, |
|
31 |
objectdata__object_id=obj.pk) |
|
30 |
return self.filter(objectdata__content_type=content_type, objectdata__object_id=obj.pk) |
|
32 | 31 |
else: |
33 | 32 |
return self.filter( |
34 |
objectdata__tag__name=tag, |
|
35 |
objectdata__content_type=content_type, |
|
36 |
objectdata__object_id=obj.pk) |
|
33 |
objectdata__tag__name=tag, objectdata__content_type=content_type, objectdata__object_id=obj.pk |
|
34 |
) |
|
37 | 35 | |
38 | 36 |
def for_objects(self, objects): |
39 |
'''Return journal records linked to any of this objects.
|
|
37 |
"""Return journal records linked to any of this objects.
|
|
40 | 38 | |
41 |
All objects must have the same model.
|
|
42 |
'''
|
|
39 |
All objects must have the same model. |
|
40 |
"""
|
|
43 | 41 |
if not objects: |
44 | 42 |
return self.none() |
45 |
content_types = [ ContentType.objects.get_for_model(obj) |
|
46 |
for obj in objects ] |
|
43 |
content_types = [ContentType.objects.get_for_model(obj) for obj in objects] |
|
47 | 44 |
if len(set(content_types)) != 1: |
48 | 45 |
raise ValueError('objects must have of the same content type') |
49 |
pks = [ obj.pk for obj in objects ] |
|
50 |
return self.filter( |
|
51 |
objectdata__content_type=content_types[0], |
|
52 |
objectdata__object_id__in=pks) |
|
46 |
pks = [obj.pk for obj in objects] |
|
47 |
return self.filter(objectdata__content_type=content_types[0], objectdata__object_id__in=pks) |
|
53 | 48 | |
54 | 49 |
def for_tag(self, tag): |
55 |
'''Returns Journal records linked to this tag by their own tag or
|
|
56 |
the tag on their data records.
|
|
57 |
'''
|
|
50 |
"""Returns Journal records linked to this tag by their own tag or
|
|
51 |
the tag on their data records. |
|
52 |
"""
|
|
58 | 53 |
from . import models |
59 | 54 | |
60 | 55 |
if not isinstance(tag, models.Tag): |
... | ... | |
64 | 59 |
return self.none() |
65 | 60 |
# always remember: multiple join (OR in WHERE) produces duplicate |
66 | 61 |
# lines ! Use .distinct() for safety. |
67 |
return self.filter(Q(tag=tag)| |
|
68 |
Q(objectdata__tag=tag)| |
|
69 |
Q(stringdata__tag=tag)) \ |
|
70 |
.distinct() |
|
62 |
return self.filter(Q(tag=tag) | Q(objectdata__tag=tag) | Q(stringdata__tag=tag)).distinct() |
|
71 | 63 | |
72 | 64 | |
73 | 65 |
class JournalManager(Manager.from_queryset(JournalQuerySet)): |
74 | 66 |
def get_query_set(self): |
75 |
return super(JournalManager, self).get_query_set() \ |
|
76 |
.prefetch_related('objectdata_set__content_type', |
|
77 |
'stringdata_set', 'objectdata_set__tag', |
|
78 |
'stringdata_set__tag', 'objectdata_set__content_object', |
|
79 |
'tag', 'template') \ |
|
80 |
.select_related('tag', 'template') |
|
67 |
return ( |
|
68 |
super(JournalManager, self) |
|
69 |
.get_query_set() |
|
70 |
.prefetch_related( |
|
71 |
'objectdata_set__content_type', |
|
72 |
'stringdata_set', |
|
73 |
'objectdata_set__tag', |
|
74 |
'stringdata_set__tag', |
|
75 |
'objectdata_set__content_object', |
|
76 |
'tag', |
|
77 |
'template', |
|
78 |
) |
|
79 |
.select_related('tag', 'template') |
|
80 |
) |
django_journal/middleware.py | ||
---|---|---|
3 | 3 | |
4 | 4 | |
5 | 5 |
class JournalMiddleware(MiddlewareMixin): |
6 |
'''Add record and error_record methods to the request object to log
|
|
7 |
current user and current REMOTE_ADRESS.
|
|
6 |
"""Add record and error_record methods to the request object to log
|
|
7 |
current user and current REMOTE_ADRESS. |
|
8 | 8 | |
9 |
It must be setup after the auth middleware.
|
|
10 |
'''
|
|
9 |
It must be setup after the auth middleware. |
|
10 |
"""
|
|
11 | 11 | |
12 | 12 |
def process_request(self, request): |
13 | 13 |
user = getattr(request, 'user', None) |
14 | 14 |
ip = request.META.get('REMOTE_ADDR', None) |
15 | ||
15 | 16 |
def record(tag, template, using=None, **kwargs): |
16 | 17 |
if 'user' not in kwargs: |
17 | 18 |
kwargs['user'] = user |
18 | 19 |
if 'ip' not in kwargs: |
19 | 20 |
kwargs['ip'] = ip |
20 |
journal.record(tag, template, using=using,**kwargs) |
|
21 |
journal.record(tag, template, using=using, **kwargs) |
|
22 | ||
21 | 23 |
def error_record(tag, template, using=None, **kwargs): |
22 | 24 |
if 'user' not in kwargs: |
23 | 25 |
kwargs['user'] = user |
24 | 26 |
if 'ip' not in kwargs: |
25 | 27 |
kwargs['ip'] = ip |
26 | 28 |
journal.error_record(tag, template, using=using, **kwargs) |
29 | ||
27 | 30 |
request.record = record |
28 | 31 |
request.error_record = error_record |
29 | 32 |
return None |
django_journal/migrations/0001_initial.py | ||
---|---|---|
15 | 15 |
migrations.CreateModel( |
16 | 16 |
name='Journal', |
17 | 17 |
fields=[ |
18 |
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), |
|
18 |
( |
|
19 |
'id', |
|
20 |
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True), |
|
21 |
), |
|
19 | 22 |
('time', models.DateTimeField(auto_now_add=True, verbose_name='time', db_index=True)), |
20 | 23 |
('message', models.CharField(max_length=128, verbose_name='message', db_index=True)), |
21 | 24 |
], |
... | ... | |
28 | 31 |
migrations.CreateModel( |
29 | 32 |
name='ObjectData', |
30 | 33 |
fields=[ |
31 |
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), |
|
34 |
( |
|
35 |
'id', |
|
36 |
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True), |
|
37 |
), |
|
32 | 38 |
('object_id', models.PositiveIntegerField(verbose_name='object id', db_index=True)), |
33 |
('content_type', models.ForeignKey(verbose_name='content type', to='contenttypes.ContentType', on_delete=models.CASCADE)), |
|
34 |
('journal', models.ForeignKey(verbose_name='journal entry', to='django_journal.Journal', on_delete=models.CASCADE)), |
|
39 |
( |
|
40 |
'content_type', |
|
41 |
models.ForeignKey( |
|
42 |
verbose_name='content type', to='contenttypes.ContentType', on_delete=models.CASCADE |
|
43 |
), |
|
44 |
), |
|
45 |
( |
|
46 |
'journal', |
|
47 |
models.ForeignKey( |
|
48 |
verbose_name='journal entry', to='django_journal.Journal', on_delete=models.CASCADE |
|
49 |
), |
|
50 |
), |
|
35 | 51 |
], |
36 | 52 |
options={ |
37 | 53 |
'verbose_name': 'linked object', |
... | ... | |
40 | 56 |
migrations.CreateModel( |
41 | 57 |
name='StringData', |
42 | 58 |
fields=[ |
43 |
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), |
|
59 |
( |
|
60 |
'id', |
|
61 |
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True), |
|
62 |
), |
|
44 | 63 |
('content', models.TextField(verbose_name='content')), |
45 |
('journal', models.ForeignKey(verbose_name='journal entry', to='django_journal.Journal', on_delete=models.CASCADE)), |
|
64 |
( |
|
65 |
'journal', |
|
66 |
models.ForeignKey( |
|
67 |
verbose_name='journal entry', to='django_journal.Journal', on_delete=models.CASCADE |
|
68 |
), |
|
69 |
), |
|
46 | 70 |
], |
47 | 71 |
options={ |
48 | 72 |
'verbose_name': 'linked text string', |
... | ... | |
51 | 75 |
migrations.CreateModel( |
52 | 76 |
name='Tag', |
53 | 77 |
fields=[ |
54 |
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), |
|
78 |
( |
|
79 |
'id', |
|
80 |
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True), |
|
81 |
), |
|
55 | 82 |
('name', models.CharField(unique=True, max_length=32, verbose_name='name', db_index=True)), |
56 | 83 |
], |
57 | 84 |
options={ |
... | ... | |
62 | 89 |
migrations.CreateModel( |
63 | 90 |
name='Template', |
64 | 91 |
fields=[ |
65 |
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), |
|
92 |
( |
|
93 |
'id', |
|
94 |
models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True), |
|
95 |
), |
|
66 | 96 |
('content', models.TextField(unique=True, verbose_name='content', db_index=True)), |
67 | 97 |
], |
68 | 98 |
options={ |
... | ... | |
82 | 112 |
migrations.AddField( |
83 | 113 |
model_name='journal', |
84 | 114 |
name='tag', |
85 |
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, verbose_name='tag', to='django_journal.Tag'), |
|
115 |
field=models.ForeignKey( |
|
116 |
on_delete=django.db.models.deletion.PROTECT, verbose_name='tag', to='django_journal.Tag' |
|
117 |
), |
|
86 | 118 |
), |
87 | 119 |
migrations.AddField( |
88 | 120 |
model_name='journal', |
89 | 121 |
name='template', |
90 |
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, verbose_name='template', to='django_journal.Template'), |
|
122 |
field=models.ForeignKey( |
|
123 |
on_delete=django.db.models.deletion.PROTECT, |
|
124 |
verbose_name='template', |
|
125 |
to='django_journal.Template', |
|
126 |
), |
|
91 | 127 |
), |
92 | 128 |
migrations.AlterUniqueTogether( |
93 | 129 |
name='stringdata', |
django_journal/models.py | ||
---|---|---|
10 | 10 | |
11 | 11 |
@python_2_unicode_compatible |
12 | 12 |
class Tag(models.Model): |
13 |
'''Tag allows typing event and data linked to events. |
|
13 |
"""Tag allows typing event and data linked to events. |
|
14 | ||
15 |
name: |
|
16 |
the string identifier of the tag |
|
17 |
""" |
|
14 | 18 | |
15 |
name: |
|
16 |
the string identifier of the tag |
|
17 |
''' |
|
18 | 19 |
objects = managers.TagManager() |
19 |
name = models.CharField(verbose_name=_('name'), max_length=32, unique=True, |
|
20 |
db_index=True) |
|
20 |
name = models.CharField(verbose_name=_('name'), max_length=32, unique=True, db_index=True) |
|
21 | 21 | |
22 | 22 |
def __str__(self): |
23 | 23 |
return self.name |
... | ... | |
32 | 32 | |
33 | 33 |
@python_2_unicode_compatible |
34 | 34 |
class Template(models.Model): |
35 |
'''Template for formatting an event. |
|
35 |
"""Template for formatting an event. |
|
36 | ||
37 |
ex.: Template( |
|
38 |
content='{user1} gave group {group} to {user2}') |
|
39 |
""" |
|
36 | 40 | |
37 |
ex.: Template( |
|
38 |
content='{user1} gave group {group} to {user2}') |
|
39 |
''' |
|
40 | 41 |
objects = managers.TemplateManager() |
41 |
content = models.TextField(verbose_name=_('content'), unique=True, |
|
42 |
db_index=True) |
|
42 |
content = models.TextField(verbose_name=_('content'), unique=True, db_index=True) |
|
43 | 43 | |
44 | 44 |
def __str__(self): |
45 | 45 |
return self.content |
... | ... | |
53 | 53 | |
54 | 54 |
@python_2_unicode_compatible |
55 | 55 |
class Journal(models.Model): |
56 |
'''One line of the journal.
|
|
56 |
"""One line of the journal.
|
|
57 | 57 | |
58 |
Each recorded event in the journal is a Journal instance. |
|
58 |
Each recorded event in the journal is a Journal instance. |
|
59 | ||
60 |
time - the time at which the event was recorded |
|
61 |
tag - the tag giving the type of event |
|
62 |
template - a format string to present the event |
|
63 |
message - a simple string representation of the event, computed using |
|
64 |
the template and associated datas. |
|
65 |
""" |
|
59 | 66 | |
60 |
time - the time at which the event was recorded |
|
61 |
tag - the tag giving the type of event |
|
62 |
template - a format string to present the event |
|
63 |
message - a simple string representation of the event, computed using |
|
64 |
the template and associated datas. |
|
65 |
''' |
|
66 | 67 |
objects = managers.JournalManager() |
67 | 68 | |
68 |
time = models.DateTimeField(verbose_name=_('time'), auto_now_add=True, |
|
69 |
db_index=True) |
|
70 |
tag = models.ForeignKey(Tag, verbose_name=_('tag'), |
|
71 |
on_delete=models.PROTECT) |
|
72 |
template = models.ForeignKey(Template, verbose_name=_('template'), |
|
73 |
on_delete=models.PROTECT) |
|
74 |
message = models.CharField(verbose_name=_('message'), max_length=128, |
|
75 |
db_index=True) |
|
69 |
time = models.DateTimeField(verbose_name=_('time'), auto_now_add=True, db_index=True) |
|
70 |
tag = models.ForeignKey(Tag, verbose_name=_('tag'), on_delete=models.PROTECT) |
|
71 |
template = models.ForeignKey(Template, verbose_name=_('template'), on_delete=models.PROTECT) |
|
72 |
message = models.CharField(verbose_name=_('message'), max_length=128, db_index=True) |
|
76 | 73 | |
77 | 74 |
class Meta: |
78 | 75 |
ordering = ('-id',) |
... | ... | |
86 | 83 |
ctx[data.tag.name] = data.content_object |
87 | 84 |
else: |
88 | 85 |
ctx[data.tag.name] = u'<deleted {content_type} {object_id}>'.format( |
89 |
content_type=data.content_type, object_id=data.object_id) |
|
86 |
content_type=data.content_type, object_id=data.object_id |
|
87 |
) |
|
90 | 88 |
for data in self.stringdata_set.all(): |
91 | 89 |
ctx[data.tag.name] = data.content |
92 | 90 |
for text, field, format_spec, conversion in string.Formatter().parse(self.template.content): |
... | ... | |
98 | 96 |
return ctx |
99 | 97 | |
100 | 98 |
def add_object_tag(self, tag_name, obj): |
101 |
ObjectData(journal=self, |
|
102 |
tag=Tag.objects.get_cached(name=tag_name), |
|
103 |
content_object=obj).save() |
|
99 |
ObjectData(journal=self, tag=Tag.objects.get_cached(name=tag_name), content_object=obj).save() |
|
104 | 100 | |
105 | 101 |
def __str__(self): |
106 | 102 |
ctx = self.message_context() |
... | ... | |
108 | 104 | |
109 | 105 |
def __repr__(self): |
110 | 106 |
return '<Journal pk:{0} tag:{1} message:{2}>'.format( |
111 |
self.pk, unicode(self.tag).encode('utf-8'),
|
|
112 |
unicode(self.message).encode('utf-8'))
|
|
107 |
self.pk, unicode(self.tag).encode('utf-8'), unicode(self.message).encode('utf-8')
|
|
108 |
) |
|
113 | 109 | |
114 | 110 | |
115 | 111 |
class StringData(models.Model): |
116 |
'''String data associated to a recorded event. |
|
117 | ||
118 |
journal: |
|
119 |
the recorded event |
|
120 |
tag: |
|
121 |
the identifier for this data |
|
122 |
content: |
|
123 |
the string value of the data |
|
124 |
''' |
|
112 |
"""String data associated to a recorded event. |
|
113 | ||
114 |
journal: |
|
115 |
the recorded event |
|
116 |
tag: |
|
117 |
the identifier for this data |
|
118 |
content: |
|
119 |
the string value of the data |
|
120 |
""" |
|
121 | ||
125 | 122 |
journal = models.ForeignKey(Journal, verbose_name=_('journal entry'), on_delete=models.CASCADE) |
126 | 123 |
tag = models.ForeignKey(Tag, verbose_name=_('tag'), on_delete=models.CASCADE) |
127 | 124 |
content = models.TextField(verbose_name=_('content')) |
... | ... | |
133 | 130 | |
134 | 131 |
@python_2_unicode_compatible |
135 | 132 |
class ObjectData(models.Model): |
136 |
'''Object data associated with a recorded event. |
|
137 | ||
138 |
journal: |
|
139 |
the recorded event |
|
140 |
tag: |
|
141 |
the identifier for this data |
|
142 |
content_object: |
|
143 |
the object value of the data |
|
144 |
''' |
|
133 |
"""Object data associated with a recorded event. |
|
134 | ||
135 |
journal: |
|
136 |
the recorded event |
|
137 |
tag: |
|
138 |
the identifier for this data |
|
139 |
content_object: |
|
140 |
the object value of the data |
|
141 |
""" |
|
142 | ||
145 | 143 |
journal = models.ForeignKey(Journal, verbose_name=_('journal entry'), on_delete=models.CASCADE) |
146 | 144 |
tag = models.ForeignKey(Tag, verbose_name=_('tag'), on_delete=models.CASCADE) |
147 |
content_type = models.ForeignKey('contenttypes.ContentType', on_delete=models.CASCADE, |
|
148 |
verbose_name=_('content type')) |
|
149 |
object_id = models.PositiveIntegerField(db_index=True, |
|
150 |
verbose_name=_('object id')) |
|
151 |
content_object = GenericForeignKey('content_type', |
|
152 |
'object_id') |
|
145 |
content_type = models.ForeignKey( |
|
146 |
'contenttypes.ContentType', on_delete=models.CASCADE, verbose_name=_('content type') |
|
147 |
) |
|
148 |
object_id = models.PositiveIntegerField(db_index=True, verbose_name=_('object id')) |
|
149 |
content_object = GenericForeignKey('content_type', 'object_id') |
|
153 | 150 | |
154 | 151 |
class Meta: |
155 | 152 |
unique_together = (('journal', 'tag'),) |
setup.py | ||
---|---|---|
22 | 22 | |
23 | 23 |
def run(self): |
24 | 24 |
import os |
25 | ||
25 | 26 |
try: |
26 | 27 |
from django.core.management import call_command |
27 | 28 |
except ImportError: |
... | ... | |
45 | 46 |
try: |
46 | 47 |
os.environ.pop('DJANGO_SETTINGS_MODULE', None) |
47 | 48 |
from django.core.management import call_command |
49 | ||
48 | 50 |
os.chdir(os.path.realpath('django_journal')) |
49 | 51 |
call_command('compilemessages') |
50 | 52 |
except ImportError: |
... | ... | |
80 | 82 | |
81 | 83 | |
82 | 84 |
def get_version(): |
83 |
'''Use the VERSION, if absent generates a version with git describe, if not
|
|
84 |
tag exists, take 0.0- and add the length of the commit log.
|
|
85 |
'''
|
|
85 |
"""Use the VERSION, if absent generates a version with git describe, if not
|
|
86 |
tag exists, take 0.0- and add the length of the commit log. |
|
87 |
"""
|
|
86 | 88 |
if os.path.exists('VERSION'): |
87 | 89 |
with open('VERSION', 'r') as v: |
88 | 90 |
return v.read() |
89 | 91 |
if os.path.exists('.git'): |
90 | 92 |
p = subprocess.Popen( |
91 | 93 |
['git', 'describe', '--dirty=.dirty', '--match=v*'], |
92 |
stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
|
94 |
stdout=subprocess.PIPE, |
|
95 |
stderr=subprocess.PIPE, |
|
96 |
) |
|
93 | 97 |
result = p.communicate()[0] |
94 | 98 |
if p.returncode == 0: |
95 | 99 |
result = result.decode('ascii').strip()[1:] # strip spaces/newlines and initial v |
... | ... | |
100 | 104 |
version = result |
101 | 105 |
return version |
102 | 106 |
else: |
103 |
return '0.0.post%s' % len( |
|
104 |
subprocess.check_output( |
|
105 |
['git', 'rev-list', 'HEAD']).splitlines()) |
|
107 |
return '0.0.post%s' % len(subprocess.check_output(['git', 'rev-list', 'HEAD']).splitlines()) |
|
106 | 108 |
return '0.0' |
107 | 109 | |
108 | 110 | |
109 |
setup(name='django-journal', |
|
110 |
version=get_version(), |
|
111 |
license='AGPLv3', |
|
112 |
description='Keep a structured -- i.e. not just log strings -- journal' |
|
113 |
' of events in your applications', |
|
114 |
url='http://dev.entrouvert.org/projects/django-journal/', |
|
115 |
download_url='http://repos.entrouvert.org/django-journal.git/', |
|
116 |
author="Entr'ouvert", |
|
117 |
author_email="info@entrouvert.com", |
|
118 |
packages=find_packages(os.path.dirname(__file__) or '.'), |
|
119 |
include_package_data=True, |
|
120 |
cmdclass={ |
|
121 |
'build': build, |
|
122 |
'install_lib': install_lib, |
|
123 |
'compile_translations': compile_translations, |
|
124 |
'sdist': eo_sdist, |
|
125 |
'test': test |
|
126 |
}, |
|
127 |
install_requires=[ |
|
128 |
'django >= 1.11,<2.3' |
|
129 |
]) |
|
111 |
setup( |
|
112 |
name='django-journal', |
|
113 |
version=get_version(), |
|
114 |
license='AGPLv3', |
|
115 |
description='Keep a structured -- i.e. not just log strings -- journal' ' of events in your applications', |
|
116 |
url='http://dev.entrouvert.org/projects/django-journal/', |
|
117 |
download_url='http://repos.entrouvert.org/django-journal.git/', |
|
118 |
author="Entr'ouvert", |
|
119 |
author_email="info@entrouvert.com", |
|
120 |
packages=find_packages(os.path.dirname(__file__) or '.'), |
|
121 |
include_package_data=True, |
|
122 |
cmdclass={ |
|
123 |
'build': build, |
|
124 |
'install_lib': install_lib, |
|
125 |
'compile_translations': compile_translations, |
|
126 |
'sdist': eo_sdist, |
|
127 |
'test': test, |
|
128 |
}, |
|
129 |
install_requires=['django >= 1.11,<2.3'], |
|
130 |
) |
test_settings.py | ||
---|---|---|
1 | 1 |
INSTALLED_APPS = ( |
2 |
'django_journal', 'django.contrib.contenttypes', 'django.contrib.auth', |
|
3 |
'django.contrib.sessions' |
|
2 |
'django_journal', |
|
3 |
'django.contrib.contenttypes', |
|
4 |
'django.contrib.auth', |
|
5 |
'django.contrib.sessions', |
|
4 | 6 |
) |
5 | 7 |
DATABASES = { |
6 |
'default': { |
|
7 |
'ENGINE': 'django.db.backends.postgresql_psycopg2', |
|
8 |
'NAME': '_test' |
|
9 |
}, |
|
10 |
'error': { |
|
11 |
'ENGINE': 'django.db.backends.postgresql_psycopg2', |
|
12 |
'NAME': '_test' |
|
13 |
} |
|
8 |
'default': {'ENGINE': 'django.db.backends.postgresql_psycopg2', 'NAME': '_test'}, |
|
9 |
'error': {'ENGINE': 'django.db.backends.postgresql_psycopg2', 'NAME': '_test'}, |
|
14 | 10 |
} |
15 | 11 | |
16 | 12 |
SECRET_KEY = "django_tests_secret_key" |
tests/test_main.py | ||
---|---|---|
15 | 15 |
self.groups = [] |
16 | 16 |
with transaction.atomic(): |
17 | 17 |
for i in range(20): |
18 |
self.users.append( |
|
19 |
User.objects.create(username='user%s' % i)) |
|
18 |
self.users.append(User.objects.create(username='user%s' % i)) |
|
20 | 19 |
for i in range(20): |
21 |
self.groups.append( |
|
22 |
Group.objects.create(name='group%s' % i)) |
|
20 |
self.groups.append(Group.objects.create(name='group%s' % i)) |
|
23 | 21 |
for i in range(20): |
24 | 22 |
record('login', '{user} logged in', user=self.users[i]) |
25 | 23 |
for i in range(20): |
26 |
record('group-changed', '{user1} gave group {group} to {user2}', |
|
27 |
user1=self.users[i], group=self.groups[i], |
|
28 |
user2=self.users[(i+1) % 20]) |
|
24 |
record( |
|
25 |
'group-changed', |
|
26 |
'{user1} gave group {group} to {user2}', |
|
27 |
user1=self.users[i], |
|
28 |
group=self.groups[i], |
|
29 |
user2=self.users[(i + 1) % 20], |
|
30 |
) |
|
29 | 31 |
for i in range(20): |
30 | 32 |
record('logout', '{user} logged out', user=self.users[i]) |
31 | 33 | |
... | ... | |
35 | 37 | |
36 | 38 |
def test_groups(self): |
37 | 39 |
for i, event in zip(range(40), Journal.objects.for_tag('group-changed').order_by('id')): |
38 |
self.assertEqual(force_text(event), |
|
39 |
'user{0} gave group group{0} to user{1}'.format(i, (i+1)%20)) |
|
40 |
self.assertEqual( |
|
41 |
force_text(event), 'user{0} gave group group{0} to user{1}'.format(i, (i + 1) % 20) |
|
42 |
) |
|
40 | 43 | |
41 | 44 |
def test_logout(self): |
42 | 45 |
for i, event in zip(range(20), Journal.objects.for_tag('logout').order_by('id')): |
... | ... | |
45 | 48 |
def test_export_as_csv(self): |
46 | 49 |
qs = Journal.objects.all() |
47 | 50 |
l = list(export_as_csv_generator(qs)) |
48 |
self.assertEquals(set(l[0]), set(['time', 'tag', 'message', 'group', 'group__id', 'user', 'user__id', 'user1', 'user1__id', 'user2', 'user2__id'])) |
|
51 |
self.assertEquals( |
|
52 |
set(l[0]), |
|
53 |
{ |
|
54 |
'time', |
|
55 |
'tag', |
|
56 |
'message', |
|
57 |
'group', |
|
58 |
'group__id', |
|
59 |
'user', |
|
60 |
'user__id', |
|
61 |
'user1', |
|
62 |
'user1__id', |
|
63 |
'user2', |
|
64 |
'user2__id', |
|
65 |
}, |
|
66 |
) |
|
49 | 67 |
l = list(export_as_csv_generator(qs[:5])) |
50 | 68 |
self.assertEquals(set(l[0]), set(['time', 'tag', 'message', 'user', 'user__id'])) |
51 | 69 |
for user in self.users: |
52 |
- |