# HG changeset patch # User ymh # Date 1500908314 -7200 # Node ID ba8bc019946473c569c41cb77bee9fa15e888f76 # Parent c653f49fabfb273108f8e1d8cebe0ecc6c823cee add log api for syncing diff -r c653f49fabfb -r ba8bc0199464 src/irinotes/settings.py --- a/src/irinotes/settings.py Thu Jul 20 23:37:58 2017 +0200 +++ b/src/irinotes/settings.py Mon Jul 24 16:58:34 2017 +0200 @@ -61,6 +61,7 @@ 'colorful', 'concurrency', 'rest_auth', + 'auditlog', 'notes', ] @@ -73,6 +74,7 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'auditlog.middleware.AuditlogMiddleware' ] ROOT_URLCONF = 'irinotes.urls' diff -r c653f49fabfb -r ba8bc0199464 src/notes/api/urls.py --- a/src/notes/api/urls.py Thu Jul 20 23:37:58 2017 +0200 +++ b/src/notes/api/urls.py Mon Jul 24 16:58:34 2017 +0200 @@ -1,6 +1,6 @@ from django.conf.urls import url, include from rest_framework_nested import routers -from .views import SessionViewSet, NoteViewSet, RootNoteViewSet +from .views import SessionViewSet, NoteViewSet, RootNoteViewSet, ListLogsView router = routers.SimpleRouter() router.register(r'sessions', SessionViewSet, base_name='session') @@ -14,4 +14,5 @@ urlpatterns = [ url(r'^', include(router.urls)), url(r'^', include(session_router.urls)), + url(r'sync/', ListLogsView.as_view(), name='sync-list') ] diff -r c653f49fabfb -r ba8bc0199464 src/notes/api/views/__init__.py --- a/src/notes/api/views/__init__.py Thu Jul 20 23:37:58 2017 +0200 +++ b/src/notes/api/views/__init__.py Mon Jul 24 16:58:34 2017 +0200 @@ -1,3 +1,4 @@ from .core import SessionViewSet, NoteViewSet, RootNoteViewSet +from .sync import ListLogsView -__all__ = ['SessionViewSet', 'NoteViewSet', 'RootNoteViewSet'] +__all__ = ['SessionViewSet', 'NoteViewSet', 'RootNoteViewSet', 'ListLogsView'] diff -r c653f49fabfb -r ba8bc0199464 src/notes/api/views/sync.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/notes/api/views/sync.py Mon Jul 24 16:58:34 2017 +0200 @@ -0,0 +1,85 @@ +import datetime + +from auditlog.models import LogEntry +from django.utils import timezone +from notes.models import Note, Session +from rest_framework import permissions +from rest_framework.response import Response +from rest_framework.views import APIView + + +class ListLogsView(APIView): + """ + View to list tle log of changes on Note and Sessions. + + * Only registered users are able to access this view. + * the results are filtered by connected user + """ + permission_classes = (permissions.IsAuthenticated,) + + def __filter_object(self, queryset, modified_since): + log_entries = LogEntry.objects.get_for_objects(queryset) + if modified_since: + log_entries = log_entries.filter(timestamp__gte=modified_since) + return log_entries + + def __process_log_entries(self, queryset, modified_since): + ''' + Process log entries + ''' + log_entries = self.__filter_object(queryset, modified_since) + + res = {} + for log_entry in log_entries: + ext_id = log_entry.additional_data.get('ext_id') + if not ext_id: + continue + sync_entry = res.get(ext_id, {}) + new_action = { + (None , LogEntry.Action.CREATE): LogEntry.Action.CREATE, + (LogEntry.Action.CREATE, LogEntry.Action.CREATE): LogEntry.Action.CREATE, + (LogEntry.Action.UPDATE, LogEntry.Action.CREATE): LogEntry.Action.UPDATE, + (LogEntry.Action.DELETE, LogEntry.Action.CREATE): LogEntry.Action.UPDATE, + + (None , LogEntry.Action.UPDATE): LogEntry.Action.UPDATE, + (LogEntry.Action.CREATE, LogEntry.Action.UPDATE): LogEntry.Action.CREATE, + (LogEntry.Action.UPDATE, LogEntry.Action.UPDATE): LogEntry.Action.UPDATE, + (LogEntry.Action.DELETE, LogEntry.Action.UPDATE): LogEntry.Action.DELETE, + + (None , LogEntry.Action.DELETE): LogEntry.Action.DELETE, + (LogEntry.Action.CREATE, LogEntry.Action.DELETE): None, + (LogEntry.Action.UPDATE, LogEntry.Action.DELETE): LogEntry.Action.DELETE, + (LogEntry.Action.DELETE, LogEntry.Action.DELETE): LogEntry.Action.DELETE, + + } [(sync_entry.get('action'), log_entry.action)] + if new_action is None: + del res[ext_id] + else: + res[ext_id] = { + 'type': log_entry.content_type.model, + 'ext_id': ext_id, + 'action': new_action, + 'timestamp': log_entry.timestamp + } + return res + + def get(self, request, format=None): + """ + Return a list of all users. + """ + modified_since_str = request.query_params.get('modified_since', None) + modified_since = None + if modified_since_str is not None: + modified_since = datetime.datetime.fromtimestamp( + float(modified_since_str), + timezone.utc + ) + + user = request.user + res_sessions = self.__process_log_entries(Session.objects.filter(owner=user), modified_since) + res_notes = self.__process_log_entries(Note.objects.filter(session__owner=user), modified_since) + + return Response({ + 'sessions': res_sessions.values(), + 'notes': res_notes.values() + }) diff -r c653f49fabfb -r ba8bc0199464 src/notes/models/core.py --- a/src/notes/models/core.py Thu Jul 20 23:37:58 2017 +0200 +++ b/src/notes/models/core.py Mon Jul 24 16:58:34 2017 +0200 @@ -1,6 +1,7 @@ """ irinotes core module """ +from auditlog.registry import auditlog from django.conf import settings from django.db import models from django.utils import timezone @@ -39,6 +40,12 @@ verbose_name=_('Session|protocol') ) + def get_additional_data(self): + return {'ext_id': str(self.ext_id)} + + def __str__(self): + return self.title + class Note(Model): """ @@ -49,6 +56,9 @@ verbose_name_plural = _('Notes') ordering = ["tc_start"] + def get_additional_data(self): + return {'ext_id': str(self.ext_id)} + tc_start = models.DateTimeField(verbose_name=_('Note|tc_start')) tc_end = models.DateTimeField(verbose_name=_('Note|tc_end')) session = models.ForeignKey( @@ -82,3 +92,5 @@ blank=True, verbose_name=_('Note|categorization') ) +auditlog.register(Session) +auditlog.register(Note) diff -r c653f49fabfb -r ba8bc0199464 src/notes/tests/api/sync.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/notes/tests/api/sync.py Mon Jul 24 16:58:34 2017 +0200 @@ -0,0 +1,233 @@ +""" +Tests the sync api +""" +import datetime +import logging + +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITransactionTestCase + +from notes.models import Session, Note +from auditlog.models import LogEntry + +logger = logging.getLogger(__name__) + + +class SyncApiTests(APITransactionTestCase): + ''' + Test Sync api + ''' + + def setUp(self): + User = get_user_model() + user1 = User.objects.create_user( + username='test_user1', + email='test_user@emial.com', + password='top_secret' + ) + user2 = User.objects.create_user( + username='test_user2', + email='test_user@emial.com', + password='top_secret' + ) + user3 = User.objects.create_user( + username='test_user3', + email='test_user@emial.com', + password='top_secret' + ) + + self.session1 = Session.objects.create( + title="a new session 1", + description="Description 1", + protocol="[]", + owner=user1 + ) + + self.session2 = Session.objects.create( + title="a new session 2", + description="Description 2", + protocol="[]", + owner=user2 + ) + + Session.objects.create( + title="a new session 3", + description="Description 3", + protocol="[]", + owner=user3 + ) + + self.note1 = Note.objects.create( + tc_start=timezone.now(), + tc_end=timezone.now(), + session=self.session1, + plain="example note 1", + html="example note 1", + raw="example note 1", + margin_note="margin note 1", + categorization="[]" + ) + + self.note2 = Note.objects.create( + tc_start=timezone.now(), + tc_end=timezone.now(), + session=self.session1, + plain="example note 1.1", + html="example note 1,1", + raw="example note 1.1", + margin_note="margin note 1.1", + categorization="[]" + ) + + + Note.objects.create( + tc_start=timezone.now(), + tc_end=timezone.now(), + session=self.session2, + plain="example note 2", + html="example note", + raw="example note", + margin_note="margin note", + categorization="[]" + ) + + def test_not_authenticated(self): + url = reverse('notes:sync-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_simple(self): + url = reverse('notes:sync-list') + self.client.login(username='test_user1', password='top_secret') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_bad_method(self): + url = reverse('notes:sync-list') + self.client.login(username='test_user1', password='top_secret') + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_simple_output(self): + url = reverse('notes:sync-list') + self.client.login(username='test_user1', password='top_secret') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + json_resp = response.json() + self.assertIn('sessions', json_resp) + self.assertEqual(1, len(json_resp['sessions'])) + self.assertEqual('session', json_resp['sessions'][0]['type']) + self.assertEqual(0, json_resp['sessions'][0]['action']) + self.assertEqual(str(self.session1.ext_id), json_resp['sessions'][0]['ext_id']) + + self.assertIn('notes', json_resp) + self.assertEqual(2, len(json_resp['notes'])) + for sync_def in json_resp['notes']: + self.assertEqual('note', sync_def['type']) + self.assertEqual(0, sync_def['action']) + self.assertIn(sync_def['ext_id'],[str(self.note1.ext_id), str(self.note2.ext_id)]) + + def test_modified_since_empty(self): + url = reverse('notes:sync-list') + self.client.login(username='test_user1', password='top_secret') + + nexthour = \ + datetime.datetime.utcnow().replace(tzinfo=timezone.utc) +\ + datetime.timedelta(hours=1) + + response = self.client.get( + url, + {'modified_since': nexthour.timestamp()}, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + json_resp = response.json() + self.assertIn('sessions', json_resp) + self.assertEqual(0, len(json_resp['sessions'])) + self.assertIn('notes', json_resp) + self.assertEqual(0, len(json_resp['notes'])) + + def test_modified_since_notes(self): + url = reverse('notes:sync-list') + self.client.login(username='test_user1', password='top_secret') + + nexthour = \ + datetime.datetime.utcnow().replace(tzinfo=timezone.utc) +\ + datetime.timedelta(hours=1) + + LogEntry.objects.filter(content_type=ContentType.objects.get_for_model(Note)).update(timestamp=nexthour) + + response = self.client.get( + url, + {'modified_since': (nexthour-datetime.timedelta(minutes=30)).timestamp()}, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + json_resp = response.json() + self.assertIn('sessions', json_resp) + self.assertEqual(0, len(json_resp['sessions'])) + self.assertIn('notes', json_resp) + self.assertEqual(2, len(json_resp['notes'])) + for sync_def in json_resp['notes']: + self.assertEqual('note', sync_def['type']) + self.assertEqual(0, sync_def['action']) + self.assertIn(sync_def['ext_id'],[str(self.note1.ext_id), str(self.note2.ext_id)]) + + + def test_modified_since_single_update(self): + self.note2.plain = "plain text modified" + self.note2.save() + + url = reverse('notes:sync-list') + self.client.login(username='test_user1', password='top_secret') + + nexthour = \ + datetime.datetime.utcnow().replace(tzinfo=timezone.utc) +\ + datetime.timedelta(hours=1) + + LogEntry.objects.filter( + content_type=ContentType.objects.get_for_model(Note), + object_pk=self.note2.id, + action=LogEntry.Action.UPDATE + ).update(timestamp=nexthour) + + response = self.client.get( + url, + {'modified_since': (nexthour-datetime.timedelta(minutes=30)).timestamp()}, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + json_resp = response.json() + self.assertIn('sessions', json_resp) + self.assertEqual(0, len(json_resp['sessions'])) + self.assertIn('notes', json_resp) + self.assertEqual(1, len(json_resp['notes'])) + sync_def = json_resp['notes'][0] + self.assertEqual(LogEntry.Action.UPDATE, sync_def['action']) + + def test_note_delete(self): + self.note2.delete() + url = reverse('notes:sync-list') + self.client.login(username='test_user1', password='top_secret') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + json_resp = response.json() + self.assertIn('sessions', json_resp) + self.assertEqual(1, len(json_resp['sessions'])) + self.assertEqual('session', json_resp['sessions'][0]['type']) + self.assertEqual(0, json_resp['sessions'][0]['action']) + self.assertEqual(str(self.session1.ext_id), json_resp['sessions'][0]['ext_id']) + + self.assertIn('notes', json_resp) + self.assertEqual(1, len(json_resp['notes'])) + sync_def = json_resp['notes'][0] + self.assertEqual('note', sync_def['type']) + self.assertEqual(0, sync_def['action']) + self.assertEqual(sync_def['ext_id'],str(self.note1.ext_id)) diff -r c653f49fabfb -r ba8bc0199464 src/requirements/base.txt --- a/src/requirements/base.txt Thu Jul 20 23:37:58 2017 +0200 +++ b/src/requirements/base.txt Mon Jul 24 16:58:34 2017 +0200 @@ -2,22 +2,25 @@ chardet==3.0.4 defusedxml==0.5.0 dj-database-url==0.4.2 -Django==1.11.2 +Django==1.11.3 django-allauth==0.32.0 +django-auditlog==0.4.3 django-colorful==1.2 -django-concurrency==1.3.2 +django-concurrency==1.4 django-cors-headers==2.1.0 -django-extensions==1.7.9 +django-extensions==1.8.1 django-filter==1.0.4 -django-guardian==1.4.8 +django-guardian==1.4.9 +django-jsonfield==1.0.1 django-rest-auth==0.9.1 djangorestframework==3.6.3 -djangorestframework-jwt==1.10.0 +djangorestframework-jwt==1.11.0 drf-nested-routers==0.90.0 idna==2.5 +irinotes==0.0.1 Markdown==2.6.8 oauthlib==2.0.2 -PyJWT==1.5.0 +PyJWT==1.5.2 python-decouple==3.0 python3-openid==3.1.0 pytz==2017.2 diff -r c653f49fabfb -r ba8bc0199464 src/setup.py --- a/src/setup.py Thu Jul 20 23:37:58 2017 +0200 +++ b/src/setup.py Mon Jul 24 16:58:34 2017 +0200 @@ -140,6 +140,7 @@ "Unipath", "dj-database-url", "six", + "django-auditlog", "django-extensions", "djangorestframework >= 3.6", "django-rest-auth[with_social]",