diff --git a/caluma/caluma_form/historical_schema.py b/caluma/caluma_form/historical_schema.py index afb8aad6f..d3f4b8a48 100644 --- a/caluma/caluma_form/historical_schema.py +++ b/caluma/caluma_form/historical_schema.py @@ -20,6 +20,13 @@ from .storage_clients import client +def filter_as_of_deleted(items, exclude_deleted=False): + if not exclude_deleted: + return items + + return [item for item in items if item.history_type != "-"] + + def historical_qs_as_of(queryset, date, pk_attr): """Get history revision as of `date` for queryset. @@ -121,14 +128,17 @@ class HistoricalFilesAnswer(FilesAnswer): HistoricalFile, required=False, as_of=graphene.types.datetime.DateTime(required=True), + exclude_deleted=graphene.Boolean(default_value=False), ) - def resolve_value(self, info, as_of, **args): + def resolve_value(self, info, as_of, exclude_deleted=False, **args): # we need to use the HistoricalFile of the correct revision - return historical_qs_as_of(models.File.history, as_of, pk_attr="id").filter( + qs = historical_qs_as_of(models.File.history, as_of, pk_attr="id").filter( answer_id=self.id ) + return filter_as_of_deleted(qs, exclude_deleted) + class Meta: model = models.Answer.history.model exclude = ("document", "date") @@ -140,6 +150,7 @@ class HistoricalDocument(FormDjangoObjectType): historical_answers = ConnectionField( HistoricalAnswerConnection, as_of=graphene.types.datetime.DateTime(required=True), + exclude_deleted=graphene.Boolean(default_value=False), ) history_date = graphene.types.datetime.DateTime(required=True) history_user_id = graphene.String() @@ -150,11 +161,13 @@ class HistoricalDocument(FormDjangoObjectType): def resolve_document_id(self, info, *args, **kwargs): return self.id - def resolve_historical_answers(self, info, as_of, *args, **kwargs): - return historical_qs_as_of( + def resolve_historical_answers(self, info, as_of, exclude_deleted=False, **args): + qs = historical_qs_as_of( models.Answer.history.filter(document_id=self.id), as_of, "id" ) + return filter_as_of_deleted(qs, exclude_deleted) + class Meta: model = models.Document.history.model exclude = ("family", "history_id", "history_change_reason") @@ -167,9 +180,10 @@ class HistoricalTableAnswer(TableAnswer): HistoricalDocument, required=False, as_of=graphene.types.datetime.DateTime(required=True), + exclude_deleted=graphene.Boolean(default_value=False), ) - def resolve_value(self, info, as_of, *args): + def resolve_value(self, info, as_of, exclude_deleted=False, **args): answerdocuments_unordered = historical_qs_as_of( models.AnswerDocument.history.filter(answer_id=self.id), as_of, "id" ) @@ -178,6 +192,7 @@ def resolve_value(self, info, as_of, *args): answerdocuments = models.AnswerDocument.history.filter( pk__in=answerdocuments_unordered ).order_by("sort") + answerdocuments = filter_as_of_deleted(answerdocuments, exclude_deleted) documents = [ models.Document.history.filter( @@ -185,6 +200,7 @@ def resolve_value(self, info, as_of, *args): ).latest("history_date") for ad in answerdocuments ] + documents = filter_as_of_deleted(documents, exclude_deleted) # Since python 3.6, `list(dict.fromkeys(somelist))` is the most performant way # to remove duplicates from a list, while retaining it's order. @@ -201,10 +217,13 @@ class Meta: interfaces = (HistoricalAnswer, graphene.Node) -def document_as_of(info, document_global_id, timestamp): +def document_as_of(info, document_global_id, timestamp, exclude_deleted=False): document_id = extract_global_id(document_global_id) document_qs = HistoricalDocument.get_queryset(models.Document.history.all(), info) document = document_qs.filter(id=document_id, history_date__lte=timestamp).first() + if exclude_deleted and document and document.history_type == "-": + document = None + if not document: raise Http404("No HistoricalDocument matches the given query.") return document @@ -215,10 +234,11 @@ class Query: HistoricalDocument, id=graphene.ID(required=True), as_of=graphene.types.datetime.DateTime(required=True), + exclude_deleted=graphene.Boolean(default_value=False), ) - def resolve_document_as_of(self, info, id, as_of): - return document_as_of(info, id, as_of) + def resolve_document_as_of(self, info, id, as_of, exclude_deleted=False): + return document_as_of(info, id, as_of, exclude_deleted) HISTORICAL_ANSWER_TYPES = { diff --git a/caluma/caluma_form/tests/__snapshots__/test_history.ambr b/caluma/caluma_form/tests/__snapshots__/test_history.ambr index f7743a965..d6aaaab0a 100644 --- a/caluma/caluma_form/tests/__snapshots__/test_history.ambr +++ b/caluma/caluma_form/tests/__snapshots__/test_history.ambr @@ -8,6 +8,7 @@ dict({ 'node': dict({ '__typename': 'HistoricalStringAnswer', + 'historyType': '+', 'historyUserId': 'admin', 'value': 'first admin - revision 1', }), @@ -28,6 +29,7 @@ dict({ 'node': dict({ '__typename': 'HistoricalStringAnswer', + 'historyType': '~', 'historyUserId': 'AnonymousUser', 'value': 'first anon - revision 3', }), @@ -48,6 +50,7 @@ dict({ 'node': dict({ '__typename': 'HistoricalStringAnswer', + 'historyType': '~', 'historyUserId': 'AnonymousUser', 'value': 'second anon - revision 4', }), @@ -59,7 +62,62 @@ }), }) # --- -# name: test_historical_table_answer +# name: test_document_as_of.3 + dict({ + 'documentAsOf': dict({ + 'documentId': '890ca108-d93d-4725-9066-7d0bddad8230', + 'historicalAnswers': dict({ + 'edges': list([ + dict({ + 'node': dict({ + '__typename': 'HistoricalStringAnswer', + 'historyType': '-', + 'historyUserId': 'AnonymousUser', + 'value': 'second anon - revision 4', + }), + }), + ]), + }), + 'meta': dict({ + }), + }), + }) +# --- +# name: test_document_as_of.4 + dict({ + 'documentAsOf': dict({ + 'documentId': '890ca108-d93d-4725-9066-7d0bddad8230', + 'historicalAnswers': dict({ + 'edges': list([ + ]), + }), + 'meta': dict({ + }), + }), + }) +# --- +# name: test_document_as_of.5 + dict({ + 'documentAsOf': dict({ + 'documentId': '890ca108-d93d-4725-9066-7d0bddad8230', + 'historicalAnswers': dict({ + 'edges': list([ + dict({ + 'node': dict({ + '__typename': 'HistoricalStringAnswer', + 'historyType': '-', + 'historyUserId': 'AnonymousUser', + 'value': 'second anon - revision 4', + }), + }), + ]), + }), + 'meta': dict({ + }), + }), + }) +# --- +# name: test_historical_table_answer[False] dict({ 'd1': dict({ 'historicalAnswers': dict({ @@ -67,6 +125,7 @@ dict({ 'node': dict({ '__typename': 'HistoricalTableAnswer', + 'historyType': '+', 'value': list([ dict({ 'historicalAnswers': dict({ @@ -79,6 +138,7 @@ }), ]), }), + 'historyType': '+', }), dict({ 'historicalAnswers': dict({ @@ -91,12 +151,14 @@ }), ]), }), + 'historyType': '+', }), ]), }), }), ]), }), + 'historyType': '+', }), 'd2': dict({ 'historicalAnswers': dict({ @@ -104,6 +166,7 @@ dict({ 'node': dict({ '__typename': 'HistoricalTableAnswer', + 'historyType': '+', 'value': list([ dict({ 'historicalAnswers': dict({ @@ -116,6 +179,7 @@ }), ]), }), + 'historyType': '+', }), dict({ 'historicalAnswers': dict({ @@ -128,12 +192,87 @@ }), ]), }), + 'historyType': '-', + }), + ]), + }), + }), + ]), + }), + 'historyType': '+', + }), + }) +# --- +# name: test_historical_table_answer[True] + dict({ + 'd1': dict({ + 'historicalAnswers': dict({ + 'edges': list([ + dict({ + 'node': dict({ + '__typename': 'HistoricalTableAnswer', + 'historyType': '+', + 'value': list([ + dict({ + 'historicalAnswers': dict({ + 'edges': list([ + dict({ + 'node': dict({ + 'historyType': '+', + 'value': 'first row value', + }), + }), + ]), + }), + 'historyType': '+', + }), + dict({ + 'historicalAnswers': dict({ + 'edges': list([ + dict({ + 'node': dict({ + 'historyType': '+', + 'value': 'second row value', + }), + }), + ]), + }), + 'historyType': '+', + }), + ]), + }), + }), + ]), + }), + 'historyType': '+', + }), + 'd2': dict({ + 'historicalAnswers': dict({ + 'edges': list([ + dict({ + 'node': dict({ + '__typename': 'HistoricalTableAnswer', + 'historyType': '+', + 'value': list([ + dict({ + 'historicalAnswers': dict({ + 'edges': list([ + dict({ + 'node': dict({ + 'historyType': '+', + 'value': 'first row value', + }), + }), + ]), + }), + 'historyType': '+', }), ]), }), }), ]), }), + 'historyType': '+', }), }) # --- diff --git a/caluma/caluma_form/tests/test_history.py b/caluma/caluma_form/tests/test_history.py index 37a890ed8..258a587b2 100644 --- a/caluma/caluma_form/tests/test_history.py +++ b/caluma/caluma_form/tests/test_history.py @@ -118,19 +118,25 @@ def test_document_as_of( timestamp3 = timezone.now() document.answers.get(question=q1.question).delete() + timestamp4 = timezone.now() + + variables = {"id": str(document.pk), "asOf": timestamp1, "excludeDeleted": False} + document.delete() + timestamp5 = timezone.now() historical_query = """ - query documentAsOf($id: ID!, $asOf: DateTime!) { - documentAsOf (id: $id, asOf: $asOf) { + query documentAsOf($id: ID!, $asOf: DateTime!, $excludeDeleted: Boolean) { + documentAsOf (id: $id, asOf: $asOf, excludeDeleted: $excludeDeleted) { meta documentId - historicalAnswers (asOf: $asOf) { + historicalAnswers (asOf: $asOf, excludeDeleted: $excludeDeleted) { edges { node { ...on HistoricalStringAnswer { __typename value historyUserId + historyType } } } @@ -139,8 +145,6 @@ def test_document_as_of( } """ - variables = {"id": str(document.pk), "asOf": timestamp1} - result = admin_schema_executor(historical_query, variable_values=variables) assert not result.errors snapshot.assert_match(result.data) @@ -155,6 +159,30 @@ def test_document_as_of( assert not result.errors snapshot.assert_match(result.data) + # Test after deletion, will show as historyType "-" + variables["asOf"] = timestamp4 + result = admin_schema_executor(historical_query, variable_values=variables) + assert not result.errors + snapshot.assert_match(result.data) + + # Test excludeDeleted=True after deletion, will show no deleted answers + variables["excludeDeleted"] = True + result = admin_schema_executor(historical_query, variable_values=variables) + assert not result.errors + snapshot.assert_match(result.data) + + # Test after document deletion, will be shown as historyType "-". + variables["asOf"] = timestamp5 + variables["excludeDeleted"] = False + result = admin_schema_executor(historical_query, variable_values=variables) + assert not result.errors + snapshot.assert_match(result.data) + + # Excluding deleted should raise 404 after document deletion + variables["excludeDeleted"] = True + result = admin_schema_executor(historical_query, variable_values=variables) + assert result.errors + variables["asOf"] = timezone.make_aware(timezone.datetime(1900, 9, 15)) result = admin_schema_executor(historical_query, variable_values=variables) assert result.errors @@ -270,8 +298,10 @@ def test_historical_file_answer( ) +@pytest.mark.parametrize("exclude_deleted", [False, True]) def test_historical_table_answer( db, + exclude_deleted, form_factory, document_factory, form_question_factory, @@ -302,7 +332,7 @@ def test_historical_table_answer( ) row2_document = document_factory(form=row_f) - answer = answer_factory( + answer2 = answer_factory( question=q_row.question, document=row2_document, value="second row value" ) @@ -312,22 +342,28 @@ def test_historical_table_answer( document=row1_document, sort=0, ) - answer_document_factory(answer=ad.answer, document=row2_document, sort=1) + ad2 = answer_document_factory(answer=ad.answer, document=row2_document, sort=1) timestamp_init = timezone.now() - answer.delete() + answer2.delete() + row2_document.delete() + ad2.delete() + timestamp_2 = timezone.now() historical_query = """ - query documentAsOf($id: ID!, $asOf1: DateTime!, $asOf2: DateTime!) { + query documentAsOf($id: ID!, $asOf1: DateTime!, $asOf2: DateTime!, $excludeDeleted: Boolean!) { d1: documentAsOf (id: $id, asOf: $asOf1) { + historyType historicalAnswers (asOf: $asOf1) { edges { node { ...on HistoricalTableAnswer { __typename + historyType value (asOf: $asOf1) { + historyType historicalAnswers (asOf: $asOf1) { edges { node { @@ -344,14 +380,17 @@ def test_historical_table_answer( } } } - d2: documentAsOf (id: $id, asOf: $asOf2) { - historicalAnswers (asOf: $asOf2) { + d2: documentAsOf (id: $id, asOf: $asOf2, excludeDeleted: $excludeDeleted) { + historyType + historicalAnswers (asOf: $asOf2, excludeDeleted: $excludeDeleted) { edges { node { ...on HistoricalTableAnswer { __typename - value (asOf: $asOf2) { - historicalAnswers (asOf: $asOf2) { + historyType + value (asOf: $asOf2, excludeDeleted: $excludeDeleted) { + historyType + historicalAnswers (asOf: $asOf2, excludeDeleted: $excludeDeleted) { edges { node { ...on HistoricalStringAnswer { @@ -374,6 +413,7 @@ def test_historical_table_answer( "id": str(main_document.pk), "asOf1": timestamp_init, "asOf2": timestamp_2, + "excludeDeleted": exclude_deleted, } result = schema_executor(historical_query, variable_values=variables) assert not result.errors diff --git a/caluma/tests/__snapshots__/test_schema.ambr b/caluma/tests/__snapshots__/test_schema.ambr index b62e37d51..dab55c21e 100644 --- a/caluma/tests/__snapshots__/test_schema.ambr +++ b/caluma/tests/__snapshots__/test_schema.ambr @@ -1501,7 +1501,7 @@ source: Document historyDate: DateTime! historyType: String - historicalAnswers(asOf: DateTime!, before: String, after: String, first: Int, last: Int): HistoricalAnswerConnection + historicalAnswers(asOf: DateTime!, excludeDeleted: Boolean = false, before: String, after: String, first: Int, last: Int): HistoricalAnswerConnection documentId: UUID } @@ -1528,7 +1528,7 @@ """The ID of the object""" id: ID! - value(asOf: DateTime!): [HistoricalFile] + value(asOf: DateTime!, excludeDeleted: Boolean = false): [HistoricalFile] meta: GenericScalar! historyUserId: String question: Question! @@ -1635,7 +1635,7 @@ """The ID of the object""" id: ID! - value(asOf: DateTime!): [HistoricalDocument] + value(asOf: DateTime!, excludeDeleted: Boolean = false): [HistoricalDocument] meta: GenericScalar! historyUserId: String question: Question! @@ -1918,7 +1918,7 @@ } type Query { - documentAsOf(id: ID!, asOf: DateTime!): HistoricalDocument + documentAsOf(id: ID!, asOf: DateTime!, excludeDeleted: Boolean = false): HistoricalDocument allAnalyticsTables(offset: Int, before: String, after: String, first: Int, last: Int, filter: [AnalyticsTableFilterSetType], order: [AnalyticsTableOrderSetType]): AnalyticsTableConnection analyticsTable(slug: String!): AnalyticsTable allAnalyticsFields(offset: Int, before: String, after: String, first: Int, last: Int, filter: [AnalyticsFieldFilterSetType], order: [AnalyticsFieldOrderSetType]): AnalyticsFieldConnection