diff --git a/server/szurubooru/api/tag_api.py b/server/szurubooru/api/tag_api.py index a9ebb96c..5ce750b4 100644 --- a/server/szurubooru/api/tag_api.py +++ b/server/szurubooru/api/tag_api.py @@ -1,6 +1,6 @@ import datetime from szurubooru import search -from szurubooru.util import auth, tags +from szurubooru.util import auth, tags, snapshots from szurubooru.api.base_api import BaseApi def _serialize_tag(tag): @@ -49,6 +49,7 @@ class TagListApi(BaseApi): ctx.session.add(tag) ctx.session.commit() tags.export_to_json(ctx.session) + snapshots.create(ctx.session, tag, ctx.user) return {'tag': _serialize_tag(tag)} class TagDetailApi(BaseApi): @@ -86,6 +87,7 @@ class TagDetailApi(BaseApi): tag.last_edit_time = datetime.datetime.now() ctx.session.commit() tags.export_to_json(ctx.session) + snapshots.modify(ctx.session, tag, ctx.user) return {'tag': _serialize_tag(tag)} def delete(self, ctx, tag_name): @@ -100,5 +102,6 @@ class TagDetailApi(BaseApi): auth.verify_privilege(ctx.user, 'tags:delete') ctx.session.delete(tag) ctx.session.commit() + snapshots.delete(ctx.session, tag, ctx.user) tags.export_to_json(ctx.session) return {} diff --git a/server/szurubooru/db/__init__.py b/server/szurubooru/db/__init__.py index 20073dfd..7448a94b 100644 --- a/server/szurubooru/db/__init__.py +++ b/server/szurubooru/db/__init__.py @@ -2,3 +2,4 @@ from szurubooru.db.base import Base from szurubooru.db.user import User from szurubooru.db.tag import Tag, TagName, TagSuggestion, TagImplication from szurubooru.db.post import Post, PostTag, PostRelation +from szurubooru.db.snapshot import Snapshot diff --git a/server/szurubooru/db/snapshot.py b/server/szurubooru/db/snapshot.py new file mode 100644 index 00000000..7772982a --- /dev/null +++ b/server/szurubooru/db/snapshot.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Integer, DateTime, String, PickleType, ForeignKey +from sqlalchemy.orm import relationship +from szurubooru.db.base import Base + +class Snapshot(Base): + __tablename__ = 'snapshot' + + OPERATION_CREATED = 'added' + OPERATION_MODIFIED = 'modified' + OPERATION_DELETED = 'deleted' + + snapshot_id = Column('id', Integer, primary_key=True) + creation_time = Column('creation_time', DateTime, nullable=False) + resource_type = Column('resource_type', String(32), nullable=False) + resource_id = Column('resource_id', Integer, nullable=False) + operation = Column('operation', String(16), nullable=False) + user_id = Column('user_id', Integer, ForeignKey('user.id')) + data = Column('data', PickleType) + + user = relationship('User') diff --git a/server/szurubooru/tests/util/test_snapshots.py b/server/szurubooru/tests/util/test_snapshots.py new file mode 100644 index 00000000..036d46a4 --- /dev/null +++ b/server/szurubooru/tests/util/test_snapshots.py @@ -0,0 +1,180 @@ +import datetime +import pytest +from szurubooru import db +from szurubooru.util import snapshots + +def test_serializing_tag(session, tag_factory): + tag = tag_factory(names=['main_name', 'alias'], category='dummy') + assert snapshots.get_tag_snapshot(tag) == { + 'names': ['main_name', 'alias'], + 'category': 'dummy' + } + + tag = tag_factory(names=['main_name', 'alias'], category='dummy') + imp1 = tag_factory(names=['imp1_main_name', 'imp1_alias']) + imp2 = tag_factory(names=['imp2_main_name', 'imp2_alias']) + sug1 = tag_factory(names=['sug1_main_name', 'sug1_alias']) + sug2 = tag_factory(names=['sug2_main_name', 'sug2_alias']) + session.add_all([imp1, imp2, sug1, sug2]) + tag.implications = [imp1, imp2] + tag.suggestions = [sug1, sug2] + session.flush() + assert snapshots.get_tag_snapshot(tag) == { + 'names': ['main_name', 'alias'], + 'category': 'dummy', + 'implications': ['imp1_main_name', 'imp2_main_name'], + 'suggestions': ['sug1_main_name', 'sug2_main_name'], + } + +def test_merging_modification_to_creation(session, tag_factory, user_factory): + tag = tag_factory(names=['dummy'], category='dummy') + user = user_factory() + session.add_all([tag, user]) + session.flush() + snapshots.create(session, tag, user) + session.flush() + tag.names = [db.TagName('changed')] + snapshots.modify(session, tag, user) + session.flush() + results = session.query(db.Snapshot).all() + assert len(results) == 1 + assert results[0].operation == db.Snapshot.OPERATION_CREATED + assert results[0].data['names'] == ['changed'] + +def test_merging_modifications( + fake_datetime, session, tag_factory, user_factory): + tag = tag_factory(names=['dummy'], category='dummy') + user = user_factory() + session.add_all([tag, user]) + session.flush() + with fake_datetime('13:00:00'): + snapshots.create(session, tag, user) + session.flush() + tag.names = [db.TagName('changed')] + with fake_datetime('14:00:00'): + snapshots.modify(session, tag, user) + session.flush() + tag.names = [db.TagName('changed again')] + with fake_datetime('14:00:01'): + snapshots.modify(session, tag, user) + session.flush() + results = session.query(db.Snapshot).all() + assert len(results) == 2 + assert results[0].operation == db.Snapshot.OPERATION_CREATED + assert results[1].operation == db.Snapshot.OPERATION_MODIFIED + assert results[0].data['names'] == ['dummy'] + assert results[1].data['names'] == ['changed again'] + +def test_not_adding_snapshot_if_data_doesnt_change( + fake_datetime, session, tag_factory, user_factory): + tag = tag_factory(names=['dummy'], category='dummy') + user = user_factory() + session.add_all([tag, user]) + session.flush() + with fake_datetime('13:00:00'): + snapshots.create(session, tag, user) + session.flush() + with fake_datetime('14:00:00'): + snapshots.modify(session, tag, user) + session.flush() + results = session.query(db.Snapshot).all() + assert len(results) == 1 + assert results[0].operation == db.Snapshot.OPERATION_CREATED + assert results[0].data['names'] == ['dummy'] + +def test_not_merging_due_to_time_difference( + fake_datetime, session, tag_factory, user_factory): + tag = tag_factory(names=['dummy'], category='dummy') + user = user_factory() + session.add_all([tag, user]) + session.flush() + with fake_datetime('13:00:00'): + snapshots.create(session, tag, user) + session.flush() + tag.names = [db.TagName('changed')] + with fake_datetime('13:10:01'): + snapshots.modify(session, tag, user) + session.flush() + assert session.query(db.Snapshot).count() == 2 + +def test_not_merging_operations_by_different_users( + fake_datetime, session, tag_factory, user_factory): + tag = tag_factory(names=['dummy'], category='dummy') + user1, user2 = [user_factory(), user_factory()] + session.add_all([tag, user1, user2]) + session.flush() + with fake_datetime('13:00:00'): + snapshots.create(session, tag, user1) + session.flush() + tag.names = [db.TagName('changed')] + snapshots.modify(session, tag, user2) + session.flush() + assert session.query(db.Snapshot).count() == 2 + +def test_merging_resets_merging_time_window( + fake_datetime, session, tag_factory, user_factory): + tag = tag_factory(names=['dummy'], category='dummy') + user = user_factory() + session.add_all([tag, user]) + session.flush() + with fake_datetime('13:00:00'): + snapshots.create(session, tag, user) + session.flush() + tag.names = [db.TagName('changed')] + with fake_datetime('13:09:59'): + snapshots.modify(session, tag, user) + session.flush() + tag.names = [db.TagName('changed again')] + with fake_datetime('13:19:59'): + snapshots.modify(session, tag, user) + session.flush() + results = session.query(db.Snapshot).all() + assert len(results) == 1 + assert results[0].data['names'] == ['changed again'] + +@pytest.mark.parametrize( + 'initial_operation', [snapshots.create, snapshots.modify]) +def test_merging_deletion_to_modification_or_creation( + fake_datetime, session, tag_factory, user_factory, initial_operation): + tag = tag_factory(names=['dummy'], category='dummy') + user = user_factory() + session.add_all([tag, user]) + session.flush() + with fake_datetime('13:00:00'): + initial_operation(session, tag, user) + session.flush() + tag.names = [db.TagName('changed')] + with fake_datetime('14:00:00'): + snapshots.modify(session, tag, user) + session.flush() + tag.names = [db.TagName('changed again')] + with fake_datetime('14:00:01'): + snapshots.delete(session, tag, user) + session.flush() + assert session.query(db.Snapshot).count() == 2 + results = session.query(db.Snapshot) \ + .order_by(db.Snapshot.snapshot_id.asc()) \ + .all() + assert results[1].operation == db.Snapshot.OPERATION_DELETED + assert results[1].data == {'names': ['changed again'], 'category': 'dummy'} + +@pytest.mark.parametrize( + 'expected_operation', [snapshots.create, snapshots.modify]) +def test_merging_deletion_all_the_way_deletes_all_snapshots( + fake_datetime, session, tag_factory, user_factory, expected_operation): + tag = tag_factory(names=['dummy'], category='dummy') + user = user_factory() + session.add_all([tag, user]) + session.flush() + with fake_datetime('13:00:00'): + snapshots.create(session, tag, user) + session.flush() + tag.names = [db.TagName('changed')] + with fake_datetime('13:00:01'): + snapshots.modify(session, tag, user) + session.flush() + tag.names = [db.TagName('changed again')] + with fake_datetime('13:00:02'): + snapshots.delete(session, tag, user) + session.flush() + assert session.query(db.Snapshot).count() == 0 diff --git a/server/szurubooru/util/snapshots.py b/server/szurubooru/util/snapshots.py new file mode 100644 index 00000000..e5eaecf9 --- /dev/null +++ b/server/szurubooru/util/snapshots.py @@ -0,0 +1,67 @@ +import datetime +from sqlalchemy.inspection import inspect +from szurubooru import db + +def get_tag_snapshot(tag): + ret = { + 'names': [tag_name.name for tag_name in tag.names], + 'category': tag.category + } + if tag.suggestions: + ret['suggestions'] = sorted(rel.first_name for rel in tag.suggestions) + if tag.implications: + ret['implications'] = sorted(rel.first_name for rel in tag.implications) + return ret + +serializers = { + 'tag': get_tag_snapshot, +} + +def save(session, operation, entity, auth_user): + table_name = entity.__table__.name + primary_key = inspect(entity).identity + assert table_name in serializers + assert len(primary_key) == 1 + primary_key = primary_key[0] + now = datetime.datetime.now() + + snapshot = db.Snapshot() + snapshot.creation_time = now + snapshot.operation = operation + snapshot.resource_type = table_name + snapshot.resource_id = primary_key + snapshot.data = serializers[table_name](entity) + snapshot.user = auth_user + + earlier_snapshots = session.query(db.Snapshot) \ + .filter(db.Snapshot.resource_type == table_name) \ + .filter(db.Snapshot.resource_id == primary_key) \ + .order_by(db.Snapshot.creation_time.desc()) \ + .all() + + delta = datetime.timedelta(minutes=10) + snapshots_left = len(earlier_snapshots) + while earlier_snapshots: + last_snapshot = earlier_snapshots.pop(0) + is_fresh = now - last_snapshot.creation_time <= delta + if snapshot.data != last_snapshot.data: + if not is_fresh or last_snapshot.user != auth_user: + break + session.delete(last_snapshot) + if snapshot.operation != db.Snapshot.OPERATION_DELETED: + snapshot.operation = last_snapshot.operation + snapshots_left -= 1 + + if not snapshots_left and operation == db.Snapshot.OPERATION_DELETED: + pass + else: + session.add(snapshot) + +def create(session, entity, auth_user): + save(session, db.Snapshot.OPERATION_CREATED, entity, auth_user) + +def modify(session, entity, auth_user): + save(session, db.Snapshot.OPERATION_MODIFIED, entity, auth_user) + +def delete(session, entity, auth_user): + save(session, db.Snapshot.OPERATION_DELETED, entity, auth_user)