From 7b3cd8f65d52e3fbf071cf5d71a261b30b7588aa Mon Sep 17 00:00:00 2001
From: Timm Fitschen <t.fitschen@indiscale.com>
Date: Fri, 27 Nov 2020 09:45:42 +0000
Subject: [PATCH] Add more functionality and properties to the Version class
 (get_history()).

Currently, the main purpose is automatic testing for the server functionality which is used by the webui.
---
 CHANGELOG.md                    |  4 +-
 src/caosdb/common/versioning.py | 80 +++++++++++++++++++++++++++++++--
 unittests/test_versioning.py    | 44 ++++++++++++++++--
 3 files changed, 120 insertions(+), 8 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6d7d84f..7ba0d8c1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added ###
 
-* Versioning support (experimental).
+* Versioning support (experimental). The version db.Version class can
+  represents particular entity versions and also the complete history of an
+  entity.
 
 ### Changed ###
 
diff --git a/src/caosdb/common/versioning.py b/src/caosdb/common/versioning.py
index bac99b73..2875486a 100644
--- a/src/caosdb/common/versioning.py
+++ b/src/caosdb/common/versioning.py
@@ -42,11 +42,19 @@ class Version():
     id : str, optional
         See attribute `id`. Default: None
     date : str, optional
-        See attribute `data`. Default: None
+        See attribute `date`. Default: None
+    username : str, optional
+        See attribute `username`. Default: None
+    realm : str, optional
+        See attribute `realm`. Default: None
     predecessors : list of Version, optional
         See attribute `predecessors`. Default: empty list.
     successors : list of Version, optional
         See attribute `successors`. Default: empty list.
+    is_head : bool
+        See attribute `is_head`. Default: False
+    is_complete_history : bool
+        See attribute `is_complete_history`. Default: False
 
     Attributes
     ----------
@@ -55,6 +63,10 @@ class Version():
     date : str
         UTC Timestamp of the version, i.e. the date and time when the entity of
         this version has been inserted or modified.
+    username : str
+        The username of the user who inserted or updated this version.
+    realm : str
+        The realm of the user who inserted or updated this version.
     predecessors : list of Version
         Predecessors are the older entity versions which have been modified
         into this version. Usually, there is only one predecessor. However,
@@ -66,14 +78,64 @@ class Version():
         is only one successor. However, this API allows that a single entity
         may co-exist in several versions (e.g. several proposals for the next
         entity status). That would result in more than one successor.
+    is_head : bool or string
+        If true, this indicates that this version is the HEAD if true.
+        Otherwise it is not known whether this is the head or not.  Any string
+        matching "true" (case-insensitively) is regarded as True.
+        Nota bene: This property should typically be set if the server response
+        indicated that this is the head version.
+    is_complete_history : bool or string
+        If true, this indicates that this version contains the full version
+        history. That means, that the predecessors and successors have their
+        respective predecessors and successors attached as well and the tree is
+        completely available.  Any string matching "true" (case-insensitively)
+        is regarded as True.
+        Nota bene: This property should typically be set if the server response
+        indicated that the full version history is included in its response.
     """
 
     # pylint: disable=redefined-builtin
-    def __init__(self, id=None, date=None, predecessors=None, successors=None):
+    def __init__(self, id=None, date=None, username=None, realm=None,
+                 predecessors=None, successors=None, is_head=False,
+                 is_complete_history=False):
+        """Typically the `predecessors` or `successors` should not "link back" to an existing Version
+object."""
         self.id = id
         self.date = date
+        self.username = username
+        self.realm = realm
         self.predecessors = predecessors if predecessors is not None else []
         self.successors = successors if successors is not None else []
+        self.is_head = str(is_head).lower() == "true"
+        self.is_complete_history = str(is_complete_history).lower() == "true"
+
+    def get_history(self):
+        """ Returns a flat list of Version instances representing the history
+        of the entity.
+
+        The list items are ordered by the relation between the versions,
+        starting with the oldest version.
+
+        The items in the list have no predecessors or successors attached.
+
+        Note: This method only returns reliable results if
+        `self.is_complete_history is True` and it will not retrieve the full
+        version history if it is not present.
+
+        Returns
+        -------
+        list of Version
+        """
+        versions = []
+        for p in self.predecessors:
+            # assuming that predecessors don't have any successors
+            versions = p.get_history()
+        versions.append(Version(id=self.id, date=self.date,
+                                username=self.username, realm=self.realm))
+        for s in self.successors:
+            # assuming that successors don't have any predecessors
+            versions.extend(s.get_history())
+        return versions
 
     def to_xml(self, tag="Version"):
         """Serialize this version to xml.
@@ -99,9 +161,15 @@ class Version():
             xml.set("id", self.id)
         if self.date is not None:
             xml.set("date", self.date)
+        if self.username is not None:
+            xml.set("username", self.username)
+        if self.realm is not None:
+            xml.set("realm", self.realm)
         if self.predecessors is not None:
             for p in self.predecessors:
                 xml.append(p.to_xml(tag="Predecessor"))
+        if self.is_head is True:
+            xml.set("head", "true")
         if self.successors is not None:
             for s in self.successors:
                 xml.append(s.to_xml(tag="Successor"))
@@ -122,8 +190,9 @@ class Version():
         Parameters
         ----------
         xml : etree.Element
-            A 'Version' xml element, with 'id' and 'date' attributes and
-            'Predecessor' and 'Successor' child elements.
+            A 'Version' xml element, with 'id', possibly 'date', `username`,
+            `realm`, and `head` attributes as well as 'Predecessor' and
+            'Successor' child elements.
 
         Returns
         -------
@@ -133,6 +202,9 @@ class Version():
         predecessors = [Version.from_xml(p) for p in xml if p.tag.lower() == "predecessor"]
         successors = [Version.from_xml(s) for s in xml if s.tag.lower() == "successor"]
         return Version(id=xml.get("id"), date=xml.get("date"),
+                       is_head=xml.get("head"),
+                       is_complete_history=xml.get("completeHistory"),
+                       username=xml.get("username"), realm=xml.get("realm"),
                        predecessors=predecessors, successors=successors)
 
     def __hash__(self):
diff --git a/unittests/test_versioning.py b/unittests/test_versioning.py
index d6009e79..5047069c 100644
--- a/unittests/test_versioning.py
+++ b/unittests/test_versioning.py
@@ -27,16 +27,21 @@ from caosdb import Record
 from caosdb.common.utils import xml2str
 from caosdb.common.versioning import Version
 from .test_property import testrecord
+from lxml import etree
 
 
 def test_constructor():
     v = Version(id="1234abcd", date="2020-01-01T20:15:00.000UTC",
+                username="testuser", realm="CaosDB", is_head=True,
                 predecessors=[Version(id="2345abdc",
                                       date="2020-01-01T20:00:00.000UTC")],
                 successors=[Version(id="3465abdc",
                                     date="2020-01-01T20:30:00.000UTC")])
     assert v.id == "1234abcd"
     assert v.date == "2020-01-01T20:15:00.000UTC"
+    assert v.username == "testuser"
+    assert v.realm == "CaosDB"
+    assert v.is_head is True
     assert isinstance(v.predecessors, list)
     assert isinstance(v.predecessors[0], Version)
     assert isinstance(v.successors, list)
@@ -48,21 +53,27 @@ def test_constructor():
 def test_to_xml():
     v = test_constructor()
     xmlstr = xml2str(v.to_xml())
-    assert xmlstr == ('<Version id="{i}" date="{d}">\n'
+    assert xmlstr == ('<Version id="{i}" date="{d}" username="{u}" realm="{r}"'
+                      ' head="{h}">\n'
                       '  <Predecessor id="{pi}" date="{pd}"/>\n'
                       '  <Successor id="{si}" date="{sd}"/>\n'
                       '</Version>\n').format(i=v.id, d=v.date,
+                                             u=v.username, r=v.realm,
+                                             h=str(v.is_head).lower(),
                                              pi=v.predecessors[0].id,
                                              pd=v.predecessors[0].date,
                                              si=v.successors[0].id,
                                              sd=v.successors[0].date)
 
     xmlstr2 = xml2str(v.to_xml(tag="OtherVersionTag"))
-    assert xmlstr2 == ('<OtherVersionTag id="{i}" date="{d}">\n'
+    assert xmlstr2 == ('<OtherVersionTag id="{i}" date="{d}" username="{u}" '
+                       'realm="{r}" head="{h}">\n'
                        '  <Predecessor id="{pi}" date="{pd}"/>\n'
                        '  <Successor id="{si}" date="{sd}"/>\n'
                        '</OtherVersionTag>\n'
-                       ).format(i=v.id, d=v.date, pi=v.predecessors[0].id,
+                       ).format(i=v.id, d=v.date, u=v.username, r=v.realm,
+                                h=str(v.is_head).lower(),
+                                pi=v.predecessors[0].id,
                                 pd=v.predecessors[0].date,
                                 si=v.successors[0].id, sd=v.successors[0].date)
 
@@ -142,3 +153,30 @@ def test_version_serialization():
     # <Record><Version id="test-version" date="asdfsadf"/></Record>
     assert "test-version" == r.to_xml().xpath("/Record/Version/@id")[0]
     assert "asdfsadf" == r.to_xml().xpath("/Record/Version/@date")[0]
+
+
+def test_get_history():
+    xml_str = """
+    <Version id="vid6" username="user1" realm="Realm1" date="date6" completeHistory="true">
+      <Predecessor id="vid5" username="user1" realm="Realm1" date="date5">
+        <Predecessor id="vid4" username="user1" realm="Realm1" date="date4">
+          <Predecessor id="vid3" username="user1" realm="Realm1" date="date3">
+            <Predecessor id="vid2" username="user1" realm="Realm1" date="date2">
+              <Predecessor id="vid1" username="user1" realm="Realm1" date="date1" />
+            </Predecessor>
+          </Predecessor>
+        </Predecessor>
+      </Predecessor>
+      <Successor id="vid7" username="user1" realm="Realm1" date="date7">
+        <Successor id="vid8" username="user1" realm="Realm1" date="date8">
+          <Successor id="vid9" username="user1" realm="Realm1" date="date9">
+            <Successor id="vid10" username="user1" realm="Realm1" date="date10" />
+          </Successor>
+        </Successor>
+      </Successor>
+    </Version>"""
+    version = Version.from_xml(etree.fromstring(xml_str))
+    assert version.is_complete_history is True
+    assert version.get_history() == [Version(id=f"vid{i+1}", date=f"date{i+1}",
+                                             username="user1", realm="Realm1")
+                                     for i in range(10)]
-- 
GitLab