diff --git a/dav_events/migrations/0034_eventchange.py b/dav_events/migrations/0034_eventchange.py new file mode 100644 index 0000000..a185f07 --- /dev/null +++ b/dav_events/migrations/0034_eventchange.py @@ -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'], + }, + ), + ] diff --git a/dav_events/migrations/0035_merge_20201103_1112.py b/dav_events/migrations/0035_merge_20201103_1112.py new file mode 100644 index 0000000..b45c133 --- /dev/null +++ b/dav_events/migrations/0035_merge_20201103_1112.py @@ -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 = [ + ] diff --git a/dav_events/models/__init__.py b/dav_events/models/__init__.py index dbddae6..30c2480 100644 --- a/dav_events/models/__init__.py +++ b/dav_events/models/__init__.py @@ -1,5 +1,6 @@ from ..roles import get_system_user, get_ghost_user from .event import Event +from .eventchange import EventChange from .eventflag import EventFlag from .eventstatus import EventStatus from .oneclickaction import OneClickAction diff --git a/dav_events/models/event.py b/dav_events/models/event.py index 22e6c15..bd0a147 100644 --- a/dav_events/models/event.py +++ b/dav_events/models/event.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import datetime import difflib +import json import logging import os import re @@ -14,14 +15,14 @@ from django.db import models from django.template.loader import get_template from django.utils.encoding import python_2_unicode_compatible 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 config from .. import signals from ..workflow import DefaultWorkflow - +from . import get_ghost_user +from .eventchange import EventChange 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) self.owner = owner creating = True - elif not implicit_update: + else: 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: self.editor = self.owner @@ -305,13 +305,50 @@ class Event(models.Model): logger.info('Event created: %s', self) signals.event_created.send(sender=self.__class__, event=self) self.workflow.update_status('draft', self.editor) - elif not implicit_update: - modified_text = self.render_as_text(show_internal_fields=True) - o_lines = original_text.split('\n') - m_lines = modified_text.split('\n') - diff_lines = list(difflib.unified_diff(o_lines, m_lines, n=len(m_lines), lineterm='')) - logger.info('Event updated: %s', self) - signals.event_updated.send(sender=self.__class__, event=self, diff=diff_lines, user=self.editor) + else: + change = EventChange(event=self, user=self.editor, operation=EventChange.UPDATE, + content=self.diff(original)) + change.save() + if not implicit_update: + logger.info('Event updated: %s', self) + 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): today = datetime.date.today() @@ -468,6 +505,7 @@ class Event(models.Model): 'course_goal_6': self.course_goal_6, 'planned_publication_date': self.planned_publication_date, 'internal_note': self.internal_note, + 'registration_closed': self.registration_closed, } if context is not None: r.update(context) diff --git a/dav_events/models/eventchange.py b/dav_events/models/eventchange.py new file mode 100644 index 0000000..a1f3084 --- /dev/null +++ b/dav_events/models/eventchange.py @@ -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) diff --git a/dav_events/templates/dav_events/event/default.txt b/dav_events/templates/dav_events/event/default.txt index b563404..c526db6 100644 --- a/dav_events/templates/dav_events/event/default.txt +++ b/dav_events/templates/dav_events/event/default.txt @@ -54,6 +54,7 @@ {% trans 'Schwierigkeitsnivau' %}: {{ event.get_level_display }} {% if event.sport == 'S' %}{% trans 'Skiliftbenutzung' %}: {% if event.ski_list %}{% trans 'Ja' %}{% else %}{% trans 'Nein' %}{% endif %} {% 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 'Veröffentlichung' %}: {% if planned_publication_date %}{{ planned_publication_date|date:'l, d. F Y' }}{% else %}{% trans 'sofort' %}{% endif %} {% if internal_note %} diff --git a/dav_events/templates/dav_events/event_detail.html b/dav_events/templates/dav_events/event_detail.html index f4b95c9..21ef44e 100644 --- a/dav_events/templates/dav_events/event_detail.html +++ b/dav_events/templates/dav_events/event_detail.html @@ -1,6 +1,7 @@ {% extends 'dav_events/base.html' %} {% load bootstrap3 %} {% load i18n %} +{% load dav_events %} {% block head-title %}{{ event }} - {{ block.super }}{% endblock head-title %} @@ -188,35 +189,77 @@
{{ event.render_as_html }} -
-
-
-
-
Status-Log
- {% for flag in event.flags.all %} -
-
- {% bootstrap_icon 'check' %} - {{ flag.status.label }}: -
-
- {{ flag.timestamp|date:'l, d. F Y, H:i' }} {% trans 'Uhr' %}
- {% trans 'von' %} {{ flag.user.get_full_name|default:flag.user }} +
+
+
+
+ +
+
+ {% for flag in event.flags.all %} +
+
+ {% bootstrap_icon 'check' %} + {{ flag.status.label }}: +
+
+ {{ flag.timestamp|date:'l, d. F Y, H:i' }} {% trans 'Uhr' %}
+ {% trans 'von' %} {{ flag.user.get_full_name|default:flag.user }} +
+
+ {% endfor %}
- {% endfor %}
-
-
{% trans 'Veröffentlichung' %}
+
+ +
+
+ {% render_event_changelog event %} +
+
+
+
+
+
+ {% if event.internal_note %} +
+
+
{% trans 'Bearbeitungshinweis' %}
+
+
+
{{ event.internal_note|linebreaksbr }}
+
+
+ {% endif %} +
+
+
{% trans 'Veröffentlichung' %}
+
+
{% if event.planned_publication_date %} {{ event.planned_publication_date|date:'l, d. F Y' }} {% else %} {% trans 'Unverzüglich' %} {% endif %} - {% if event.internal_note %} -
{% trans 'Bearbeitungshinweis' %}
-
{{ event.internal_note|linebreaksbr }}
- {% endif %}
diff --git a/dav_events/templatetags/dav_events.py b/dav_events/templatetags/dav_events.py index f5731f2..4449da5 100644 --- a/dav_events/templatetags/dav_events.py +++ b/dav_events/templatetags/dav_events.py @@ -1,7 +1,12 @@ +# -*- coding: utf-8 -*- +import json from django import template from django.utils.html import format_html 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 register = template.Library() @@ -30,3 +35,86 @@ def render_event_status(event, show_void=True): context=context) return mark_safe(html) + + +@register.simple_tag +def render_event_changelog(event): + change_templ = u'
  • \n' \ + u'\t

    ' \ + u'' \ + u' {timestamp}' \ + u' - ' \ + u' {user}

    \n' \ + u'\t{content}\n' \ + u'
  • \n' + update_sub_templ = u'
  • \n' \ + u'\t{field}:{separator1}\n' \ + u'\t{refer}\n' \ + u'\t{separator2}\n' \ + u'\t{current}\n' \ + u'
  • \n' + raise_flag_templ = u'' \ + u' {label}' \ + u' ' + lower_flag_templ = u'' \ + u' {label}' \ + u' ' + + if event.changes.exists(): + html = u'
      \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'
        ' + 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'
        ' + separator2 = u'
        ' + else: + separator1 = u' ' + separator2 = u' -> ' + 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'
      ' + 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'
    \n' + else: + html = _(u'Keine Einträge') + return mark_safe(html) diff --git a/dav_events/tests/test_emails.py b/dav_events/tests/test_emails.py index 13b33f0..2e3c3e8 100644 --- a/dav_events/tests/test_emails.py +++ b/dav_events/tests/test_emails.py @@ -49,6 +49,7 @@ Link zur Veranstaltung: Veranstaltungsart: gemeinschaftliche Tour Schwierigkeitsnivau: Anfänger Gelände: Kletterhalle + Anmeldung: Nicht erforderlich Anreise des Kurs-/Tourenleiters am Vortag: Nein Veröffentlichung: sofort """ diff --git a/dav_events/tests/test_models.py b/dav_events/tests/test_models.py new file mode 100644 index 0000000..48c0c65 --- /dev/null +++ b/dav_events/tests/test_models.py @@ -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) diff --git a/dav_events/workflow.py b/dav_events/workflow.py index 69195d5..1856480 100644 --- a/dav_events/workflow.py +++ b/dav_events/workflow.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from . import emails from . import signals +from .models.eventchange import EventChange from .models.eventflag import EventFlag from .models.eventstatus import get_or_create_event_status from .roles import get_users_by_role, has_role @@ -50,6 +51,8 @@ class BasicWorkflow(object): kwargs['status'] = status flag = EventFlag(**kwargs) 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) return flag @@ -312,10 +315,9 @@ class BasicWorkflow(object): if not app_config.settings.enable_email_on_update: return - if len(diff) < 1: + if not diff: logger.debug('send_emails_on_update(): No diff data -> Skip sending mails.') return - diff_text = '\n'.join(diff[3:]) # Who should be informed about the update? recipients = [event.owner] @@ -329,7 +331,7 @@ class BasicWorkflow(object): for recipient in recipients: 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() def send_emails_on_status_update(self, flag):