diff --git a/dav_events/migrations/0034_eventchange.py b/dav_events/migrations/0034_eventchange.py new file mode 100644 index 0000000..e83f74b --- /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 10:11 +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')], 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/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..9f9a1ef 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 @@ -16,12 +17,12 @@ 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 . 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,40 @@ 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='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, diff=self.diff(original, fmt='human_readable'), user=self.editor) + + 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) + to_value = getattr(self, field_name) + if from_value != to_value: + change = { + 'field': field_name, + 'refer': str(from_value), + 'current': str(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() diff --git a/dav_events/models/eventchange.py b/dav_events/models/eventchange.py new file mode 100644 index 0000000..8aa5745 --- /dev/null +++ b/dav_events/models/eventchange.py @@ -0,0 +1,36 @@ +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 + +CHANGE_OPERATIONS = ( + ('update', 'update'), +) + + +def get_system_user_id(): + return get_system_user().id + + +@python_2_unicode_compatible +class EventChange(models.Model): + 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=CHANGE_OPERATIONS) + 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/tests/test_models.py b/dav_events/tests/test_models.py new file mode 100644 index 0000000..0ca1c1c --- /dev/null +++ b/dav_events/tests/test_models.py @@ -0,0 +1,54 @@ +from __future__ import unicode_literals +import datetime +import json +from django.test import TestCase + +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_empty_changelog(self): + data = TEST_EVENT_DATA + event = self.create_event_by_model(data) + event.sport = 'M' + self.assertFalse(event.changes.exists()) + + 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.max_participants = 8 + event.save() + + changes = event.changes + self.assertEqual(changes.count(), 2) + + subchanges = json.loads(changes.first().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) + + subchanges = json.loads(changes.last().content) + self.assertEqual(len(subchanges), 2) + self.assertIn({'field': 'country', 'refer': 'DE', 'current': 'FR'}, 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..8538882 100644 --- a/dav_events/workflow.py +++ b/dav_events/workflow.py @@ -312,10 +312,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 +328,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):