Add a simple changelog to the events #26

Merged
heinzel merged 7 commits from changelog into master 2020-11-03 12:34:30 +01:00
11 changed files with 370 additions and 36 deletions

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-09-29 20:15
from __future__ import unicode_literals
import dav_events.models.eventchange
import dav_events.roles
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('dav_events', '0033_auto_20200925_1543'),
]
operations = [
migrations.CreateModel(
name='EventChange',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp', models.DateTimeField(default=django.utils.timezone.now)),
('operation', models.CharField(choices=[('update', 'Update'), ('set_flag', 'Raise Flag'), ('unset_flag', 'Lower Flag')], max_length=20)),
('content', models.TextField()),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changes', to='dav_events.Event')),
('user', models.ForeignKey(default=dav_events.models.eventchange.get_system_user_id, on_delete=models.SET(dav_events.roles.get_ghost_user), related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['event', 'timestamp'],
},
),
]

View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2020-11-03 10:12
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dav_events', '0034_auto_20201015_1738'),
('dav_events', '0034_eventchange'),
]
operations = [
]

View File

@@ -1,5 +1,6 @@
from ..roles import get_system_user, get_ghost_user from ..roles import get_system_user, get_ghost_user
from .event import Event from .event import Event
from .eventchange import EventChange
from .eventflag import EventFlag from .eventflag import EventFlag
from .eventstatus import EventStatus from .eventstatus import EventStatus
from .oneclickaction import OneClickAction from .oneclickaction import OneClickAction

View File

@@ -2,6 +2,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import datetime import datetime
import difflib import difflib
import json
import logging import logging
import os import os
import re import re
@@ -14,14 +15,14 @@ from django.db import models
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import get_language, ugettext_lazy as _ from django.utils.translation import get_language, ugettext_lazy as _
from django_countries.fields import CountryField from django_countries.fields import Country, CountryField
from . import get_ghost_user
from .. import choices from .. import choices
from .. import config from .. import config
from .. import signals from .. import signals
from ..workflow import DefaultWorkflow from ..workflow import DefaultWorkflow
from . import get_ghost_user
from .eventchange import EventChange
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -292,9 +293,8 @@ class Event(models.Model):
logger.warning('Event is not created by its owner (Current user: %s, Owner: %s)!', self.editor, owner) logger.warning('Event is not created by its owner (Current user: %s, Owner: %s)!', self.editor, owner)
self.owner = owner self.owner = owner
creating = True creating = True
elif not implicit_update: else:
original = Event.objects.get(id=self.id) original = Event.objects.get(id=self.id)
original_text = original.render_as_text(show_internal_fields=True)
if not self.editor or not self.editor.is_authenticated: if not self.editor or not self.editor.is_authenticated:
self.editor = self.owner self.editor = self.owner
@@ -305,13 +305,50 @@ class Event(models.Model):
logger.info('Event created: %s', self) logger.info('Event created: %s', self)
signals.event_created.send(sender=self.__class__, event=self) signals.event_created.send(sender=self.__class__, event=self)
self.workflow.update_status('draft', self.editor) self.workflow.update_status('draft', self.editor)
elif not implicit_update: else:
modified_text = self.render_as_text(show_internal_fields=True) change = EventChange(event=self, user=self.editor, operation=EventChange.UPDATE,
o_lines = original_text.split('\n') content=self.diff(original))
m_lines = modified_text.split('\n') change.save()
diff_lines = list(difflib.unified_diff(o_lines, m_lines, n=len(m_lines), lineterm='')) if not implicit_update:
logger.info('Event updated: %s', self) logger.info('Event updated: %s', self)
signals.event_updated.send(sender=self.__class__, event=self, diff=diff_lines, user=self.editor) signals.event_updated.send(sender=self.__class__, event=self, user=self.editor,
diff=self.diff(original, fmt='human_readable'))
def diff(self, event, fmt='json'):
if fmt == 'human_readable':
from_text = event.render_as_text(show_internal_fields=True)
to_text = self.render_as_text(show_internal_fields=True)
from_lines = from_text.split('\n')
to_lines = to_text.split('\n')
diff_lines = list(difflib.unified_diff(from_lines, to_lines, n=len(from_lines), lineterm=''))
diff_text = '\n'.join(diff_lines[3:])
elif fmt == 'json':
fields = self._meta.get_fields()
changes = []
for field in fields:
field_name = field.name
from_value = getattr(event, field_name)
try:
json.dumps(from_value)
except TypeError:
from_value = str(from_value)
to_value = getattr(self, field_name)
try:
json.dumps(to_value)
except TypeError:
to_value = str(to_value)
if from_value != to_value:
change = {
'field': field_name,
'refer': from_value,
'current': to_value,
}
changes.append(change)
diff_text = json.dumps(changes)
else:
raise ValueError("Event.diff(): Unsupported format: {}".format(fmt))
return diff_text
def is_deadline_expired(self): def is_deadline_expired(self):
today = datetime.date.today() today = datetime.date.today()
@@ -468,6 +505,7 @@ class Event(models.Model):
'course_goal_6': self.course_goal_6, 'course_goal_6': self.course_goal_6,
'planned_publication_date': self.planned_publication_date, 'planned_publication_date': self.planned_publication_date,
'internal_note': self.internal_note, 'internal_note': self.internal_note,
'registration_closed': self.registration_closed,
} }
if context is not None: if context is not None:
r.update(context) r.update(context)

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from . import get_ghost_user, get_system_user
def get_system_user_id():
return get_system_user().id
@python_2_unicode_compatible
class EventChange(models.Model):
UPDATE = 'update'
RAISE_FLAG = 'set_flag'
LOWER_FLAG = 'unset_flag'
OPERATION_CHOICES = (
(UPDATE, 'Update'),
(RAISE_FLAG, 'Raise Flag'),
(LOWER_FLAG, 'Lower Flag'),
)
event = models.ForeignKey('dav_events.Event', related_name='changes')
timestamp = models.DateTimeField(default=timezone.now)
user = models.ForeignKey(settings.AUTH_USER_MODEL,
default=get_system_user_id,
on_delete=models.SET(get_ghost_user),
related_name='+')
operation = models.CharField(max_length=20, choices=OPERATION_CHOICES)
content = models.TextField()
class Meta:
ordering = ['event', 'timestamp']
def __str__(self):
s = '{timestamp} - {user} - {operation}'
return s.format(operation=self.operation, timestamp=self.timestamp.strftime('%d.%m.%Y %H:%M:%S %Z'),
user=self.user)

View File

@@ -54,6 +54,7 @@
{% trans 'Schwierigkeitsnivau' %}: {{ event.get_level_display }} {% trans 'Schwierigkeitsnivau' %}: {{ event.get_level_display }}
{% if event.sport == 'S' %}{% trans 'Skiliftbenutzung' %}: {% if event.ski_list %}{% trans 'Ja' %}{% else %}{% trans 'Nein' %}{% endif %} {% if event.sport == 'S' %}{% trans 'Skiliftbenutzung' %}: {% if event.ski_list %}{% trans 'Ja' %}{% else %}{% trans 'Nein' %}{% endif %}
{% endif %}{% trans 'Gelände' %}: {{ event.get_terrain_display }} {% endif %}{% trans 'Gelände' %}: {{ event.get_terrain_display }}
{% trans 'Anmeldung' %}: {% if registration_required %}{% if registration_closed %}{% trans 'Geschlossen' %}{% else %}{% trans 'Erforderlich' %}{% endif %}{% else %}{% trans 'Nicht erforderlich' %}{% endif %}
{% trans 'Anreise des Kurs-/Tourenleiters am Vortag' %}: {% if event.arrival_previous_day %}{% trans 'Ja' %}{% else %}{% trans 'Nein' %}{% endif %} {% trans 'Anreise des Kurs-/Tourenleiters am Vortag' %}: {% if event.arrival_previous_day %}{% trans 'Ja' %}{% else %}{% trans 'Nein' %}{% endif %}
{% trans 'Veröffentlichung' %}: {% if planned_publication_date %}{{ planned_publication_date|date:'l, d. F Y' }}{% else %}{% trans 'sofort' %}{% endif %} {% trans 'Veröffentlichung' %}: {% if planned_publication_date %}{{ planned_publication_date|date:'l, d. F Y' }}{% else %}{% trans 'sofort' %}{% endif %}
{% if internal_note %} {% if internal_note %}

View File

@@ -1,6 +1,7 @@
{% extends 'dav_events/base.html' %} {% extends 'dav_events/base.html' %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load i18n %} {% load i18n %}
{% load dav_events %}
{% block head-title %}{{ event }} - {{ block.super }}{% endblock head-title %} {% block head-title %}{{ event }} - {{ block.super }}{% endblock head-title %}
@@ -188,35 +189,77 @@
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
{{ event.render_as_html }} {{ event.render_as_html }}
<div class="panel panel-default"> <div class="row">
<div class="panel-body"> <div class="col-sm-7">
<div class="row"> <div class="panel-group" id="log-accordion" role="tablist" aria-multiselectable="true">
<div class="col-sm-7"> <div class="panel panel-default">
<h5>Status-Log</h5> <div id="headingStatusLog" class="panel-heading" role="tab">
{% for flag in event.flags.all %} <h5 class="panel-title">
<div class="row"> <a role="button" href="#collapseStatusLog" data-toggle="collapse"
<div class="col-sm-5"> data-parent="#log-accordion" aria-expanded="true"
<span class="text-{{ flag.status.bootstrap_context|default:'default' }}">{% bootstrap_icon 'check' %}</span> aria-controls="collapseStatusLog">
<strong>{{ flag.status.label }}:</strong> Status-Flags
</div> </a>
<div class="col-sm-7"> </h5>
{{ flag.timestamp|date:'l, d. F Y, H:i' }} {% trans 'Uhr' %}<br /> </div>
{% trans 'von' %} {{ flag.user.get_full_name|default:flag.user }} <div id="collapseStatusLog" class="panel-collapse collapse in"
role="tabpanel" aria-labelledby="headingStatusLog">
<div class="panel-body">
{% for flag in event.flags.all %}
<div class="row">
<div class="col-sm-4">
<span class="text-{{ flag.status.bootstrap_context|default:'default' }}">{% bootstrap_icon 'check' %}</span>
<strong>{{ flag.status.label }}:</strong>
</div>
<div class="col-sm-8">
{{ flag.timestamp|date:'l, d. F Y, H:i' }} {% trans 'Uhr' %}<br />
{% trans 'von' %} {{ flag.user.get_full_name|default:flag.user }}
</div>
</div>
{% endfor %}
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
<div class="col-sm-5"> <div class="panel panel-default">
<h5>{% trans 'Veröffentlichung' %}</h5> <div id="headingChangeLog" class="panel-heading" role="tab">
<h5 class="panel-title">
<a role="button" href="#collapseChangeLog" data-toggle="collapse"
data-parent="#log-accordion" aria-expanded="true"
aria-controls="collapseChangeLog">
Change-Log
</a>
</h5>
</div>
<div id="collapseChangeLog" class="panel-collapse collapse"
role="tabpanel" aria-labelledby="headingChangeLog">
<div class="panel-body">
{% render_event_changelog event %}
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-5">
{% if event.internal_note %}
<div class="panel panel-danger">
<div class="panel-heading">
<h5 class="panel-title">{% trans 'Bearbeitungshinweis' %}</h5>
</div>
<div class="panel-body">
<div><small>{{ event.internal_note|linebreaksbr }}</small></div>
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<h5 class="panel-title">{% trans 'Veröffentlichung' %}</h5>
</div>
<div class="panel-body">
{% if event.planned_publication_date %} {% if event.planned_publication_date %}
{{ event.planned_publication_date|date:'l, d. F Y' }} {{ event.planned_publication_date|date:'l, d. F Y' }}
{% else %} {% else %}
{% trans 'Unverzüglich' %} {% trans 'Unverzüglich' %}
{% endif %} {% endif %}
{% if event.internal_note %}
<h5 style="margin-top: 1em;">{% trans 'Bearbeitungshinweis' %}</h5>
<div class="well well-sm"><small>{{ event.internal_note|linebreaksbr }}</small></div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,12 @@
# -*- coding: utf-8 -*-
import json
from django import template from django import template
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils import timezone
from django.utils.translation import ugettext as _
from ..models.eventchange import EventChange
from ..models.eventstatus import EventStatus, get_or_create_event_status from ..models.eventstatus import EventStatus, get_or_create_event_status
register = template.Library() register = template.Library()
@@ -30,3 +35,86 @@ def render_event_status(event, show_void=True):
context=context) context=context)
return mark_safe(html) return mark_safe(html)
@register.simple_tag
def render_event_changelog(event):
change_templ = u'<li class="list-group-item">\n' \
u'\t<p class="list-group-item-heading">' \
u'<span class="glyphicon glyphicon-{icon}"></span>' \
u' {timestamp}' \
u' - ' \
u' {user}</p>\n' \
u'\t{content}\n' \
u'</li>\n'
update_sub_templ = u'<li class="list-group-item">\n' \
u'\t{field}:{separator1}\n' \
u'\t<span style="background-color: #ffe0e0;">{refer}</span>\n' \
u'\t{separator2}\n' \
u'\t<span style="background-color: #e0ffe0;">{current}</span>\n' \
u'</li>\n'
raise_flag_templ = u'<span class="text-success glyphicon glyphicon-plus"></span>' \
u' <span class="label label-{bcontext}">{label}</span>' \
u' <span class="text-success glyphicon glyphicon-plus"></span>'
lower_flag_templ = u'<span class="text-danger glyphicon glyphicon-minus"></span>' \
u' <del><span class="label label-{bcontext}">{label}</span></del>' \
u' <span class="text-danger glyphicon glyphicon-minus"></span>'
if event.changes.exists():
html = u'<ul class="list-group">\n'
for change in event.changes.all():
username = change.user.get_full_name()
if not username:
username = change.user
if change.operation == EventChange.UPDATE:
icon = u'pencil'
content_html = u'<ul class="list-group">'
subchanges = json.loads(change.content)
for subchange in subchanges:
field_label = event._meta.get_field(subchange['field']).verbose_name
try:
is_long_strings = (len(subchange['refer']) + len(subchange['current'])) > 20
except TypeError:
is_long_strings = False
if is_long_strings:
separator1 = u'<br />'
separator2 = u'<br />'
else:
separator1 = u' '
separator2 = u' -&gt; '
content_html += format_html(update_sub_templ,
field=field_label,
separator1=mark_safe(separator1),
refer=subchange['refer'],
separator2=mark_safe(separator2),
current=subchange['current'])
content_html += u'</ul>'
elif change.operation == EventChange.RAISE_FLAG:
icon = u'flag'
status = get_or_create_event_status(change.content)
content_html = format_html(raise_flag_templ,
bcontext=status.bootstrap_context,
label=status.label)
elif change.operation == EventChange.LOWER_FLAG:
icon = u'flag'
status = get_or_create_event_status(change.content)
content_html = format_html(lower_flag_templ,
bcontext=status.bootstrap_context,
label=status.label)
else:
icon = u'question-sign'
content_html = format_html(u'{content}', content=change.content)
html += format_html(change_templ,
icon=icon,
timestamp=timezone.localtime(change.timestamp).strftime('%Y-%m-%d %H:%M:%S %Z'),
user=username,
content=mark_safe(content_html))
html += u'</ul>\n'
else:
html = _(u'Keine Einträge')
return mark_safe(html)

View File

@@ -49,6 +49,7 @@ Link zur Veranstaltung:
Veranstaltungsart: gemeinschaftliche Tour Veranstaltungsart: gemeinschaftliche Tour
Schwierigkeitsnivau: Anfänger Schwierigkeitsnivau: Anfänger
Gelände: Kletterhalle Gelände: Kletterhalle
Anmeldung: Nicht erforderlich
Anreise des Kurs-/Tourenleiters am Vortag: Nein Anreise des Kurs-/Tourenleiters am Vortag: Nein
Veröffentlichung: sofort Veröffentlichung: sofort
""" """

View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime
import json
from django.test import TestCase
from ..models.eventchange import EventChange
from .generic import EventMixin
TEST_EVENT_DATA = {
'title': 'Täst',
'description': 'Teßt',
'mode': 'joint',
'sport': 'W',
'level': 'beginner',
'first_day': datetime.date(2019, 3, 1),
'country': 'DE',
'trainer_firstname': 'Übungsleiter',
'trainer_familyname': 'Weißalles',
'trainer_email': 'trainer@localhost',
}
class EventsTestCase(EventMixin, TestCase):
def test_changelog(self):
data = TEST_EVENT_DATA
event = self.create_event_by_model(data)
event.alt_first_day = event.first_day + datetime.timedelta(1)
event.sport = 'M'
event.ski_lift = True
event.save()
event.country = 'FR'
event.save()
event.trainer_familyname += '-Ömlaut'
event.max_participants = 8
event.save()
changes = event.changes
self.assertEqual(changes.count(), 4)
change = changes.get(pk=1)
self.assertEqual(change.operation, EventChange.RAISE_FLAG)
self.assertEqual(change.content, 'draft')
change = changes.get(pk=2)
self.assertEqual(change.operation, EventChange.UPDATE)
subchanges = json.loads(change.content)
self.assertEqual(len(subchanges), 3)
self.assertIn({'field': 'alt_first_day', 'refer': None, 'current': '2019-03-02'}, subchanges)
self.assertIn({'field': 'sport', 'refer': 'W', 'current': 'M'}, subchanges)
self.assertIn({'field': 'ski_lift', 'refer': False, 'current': True}, subchanges)
change = changes.get(pk=3)
self.assertEqual(change.operation, EventChange.UPDATE)
subchanges = json.loads(change.content)
self.assertEqual(len(subchanges), 1)
self.assertIn({'field': 'country', 'refer': 'DE', 'current': 'FR'}, subchanges)
change = changes.get(pk=4)
self.assertEqual(change.operation, EventChange.UPDATE)
subchanges = json.loads(change.content)
self.assertEqual(len(subchanges), 2)
self.assertIn({'field': 'trainer_familyname', 'refer': 'Weißalles', 'current': 'Weißalles-Ömlaut'}, subchanges)
self.assertIn({'field': 'max_participants', 'refer': 0, 'current': 8}, subchanges)

View File

@@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
from . import emails from . import emails
from . import signals from . import signals
from .models.eventchange import EventChange
from .models.eventflag import EventFlag from .models.eventflag import EventFlag
from .models.eventstatus import get_or_create_event_status from .models.eventstatus import get_or_create_event_status
from .roles import get_users_by_role, has_role from .roles import get_users_by_role, has_role
@@ -50,6 +51,8 @@ class BasicWorkflow(object):
kwargs['status'] = status kwargs['status'] = status
flag = EventFlag(**kwargs) flag = EventFlag(**kwargs)
flag.save() flag.save()
change = EventChange(event=event, user=flag.user, operation=EventChange.RAISE_FLAG, content=status.code)
change.save()
logger.info('Flagging status \'%s\' for %s', status.code, event) logger.info('Flagging status \'%s\' for %s', status.code, event)
return flag return flag
@@ -312,10 +315,9 @@ class BasicWorkflow(object):
if not app_config.settings.enable_email_on_update: if not app_config.settings.enable_email_on_update:
return return
if len(diff) < 1: if not diff:
logger.debug('send_emails_on_update(): No diff data -> Skip sending mails.') logger.debug('send_emails_on_update(): No diff data -> Skip sending mails.')
return return
diff_text = '\n'.join(diff[3:])
# Who should be informed about the update? # Who should be informed about the update?
recipients = [event.owner] recipients = [event.owner]
@@ -329,7 +331,7 @@ class BasicWorkflow(object):
for recipient in recipients: for recipient in recipients:
if recipient.email and recipient.email != updater.email: if recipient.email and recipient.email != updater.email:
email = emails.EventUpdatedMail(recipient=recipient, event=event, editor=updater, diff=diff_text) email = emails.EventUpdatedMail(recipient=recipient, event=event, editor=updater, diff=diff)
email.send() email.send()
def send_emails_on_status_update(self, flag): def send_emails_on_status_update(self, flag):