From aa54e250fd836ee1c6ae3d1d427a4cd98fe94bd4 Mon Sep 17 00:00:00 2001
From: Dean Rasheed <dar17@cam.ac.uk>
Date: Mon, 30 Mar 2020 14:00:28 +0100
Subject: [PATCH] New API methods to query Lookup for changes.

---
 src/application.wadl                          | 189 +++++++++++++
 src/generate-client-methods.py                |  12 +-
 .../ac/cam/ucs/ibis/methods/GroupMethods.java |  61 +++++
 .../ac/cam/ucs/ibis/methods/IbisMethods.java  |  28 ++
 .../ucs/ibis/methods/InstitutionMethods.java  |  67 +++++
 .../cam/ucs/ibis/methods/PersonMethods.java   |  68 +++++
 src/php/ibisclient/methods/GroupMethods.php   |  60 ++++
 src/php/ibisclient/methods/IbisMethods.php    |  27 ++
 .../ibisclient/methods/InstitutionMethods.php |  66 +++++
 src/php/ibisclient/methods/PersonMethods.php  |  67 +++++
 src/php/test/UnitTests.php                    | 256 ++++++++++++++++-
 src/python/ibisclient/methods.py              | 258 ++++++++++++++++++
 src/python/test/unittests.py                  | 205 +++++++++++++-
 src/python3/ibisclient/methods.py             | 258 ++++++++++++++++++
 src/python3/test/unittests.py                 | 205 +++++++++++++-
 15 files changed, 1799 insertions(+), 28 deletions(-)

diff --git a/src/application.wadl b/src/application.wadl
index 004c2a6..313620d 100644
--- a/src/application.wadl
+++ b/src/application.wadl
@@ -9,6 +9,28 @@
  *
  * @author Dean Rasheed (dev-group@ucs.cam.ac.uk)
  */</doc>
+            <resource path="last-transaction">
+                <method id="getLastTransactionId" name="GET" resultField="value:long">
+                    <doc>
+    /**
+     * Get the ID of the last (most recent) transaction.
+     * &lt;p&gt;
+     * A transaction represents an edit made to data in Lookup. Each
+     * transaction is assigned a unique, sequential, numeric ID. Thus
+     * this last transaction ID will increase each time some data in
+     * Lookup is changed.
+     *
+     * @return The ID of the latest transaction.
+     */</doc>
+                    <response>
+                        <representation mediaType="text/plain"/>
+                        <representation mediaType="application/xml"/>
+                        <representation mediaType="application/json"/>
+                        <representation mediaType="text/xml"/>
+                        <representation mediaType="text/x-json"/>
+                    </response>
+                </method>
+            </resource>
             <resource path="version">
                 <method id="getVersion" name="GET" resultField="value:String">
                     <doc>
@@ -152,6 +174,58 @@
                     </response>
                 </method>
             </resource>
+            <resource path="modified-groups">
+                <method id="modifiedGroups" name="GET" resultField="groups:java.util.List&lt;IbisGroup&gt;">
+                    <doc>
+    /**
+     * Find all groups modified between the specified pair of transactions.
+     * &lt;p&gt;
+     * The transaction IDs specified should be the IDs from two different
+     * requests for the last (most recent) transaction ID, made at different
+     * times, that returned different values, indicating that some Lookup
+     * data was modified in the period between the two requests. This method
+     * then determines which (if any) groups were affected.
+     * &lt;p&gt;
+     * By default, only a few basic details about each group are returned,
+     * but the optional &lt;code&gt;fetch&lt;/code&gt; parameter may be used to fetch
+     * additional attributes or references.
+     * &lt;p&gt;
+     * NOTE: All data returned reflects the latest available data about each
+     * group. It is not possible to query for old data, or more detailed
+     * information about the specific changes made.
+     *
+     * @param minTxId [required] Include modifications made in transactions
+     * after (but not including) this one.
+     * @param maxTxId [required] Include modifications made in transactions
+     * up to and including this one.
+     * @param groupids [optional] Only include groups with IDs or names in
+     * this list. By default, all modified groups will be included.
+     * @param includeCancelled  [optional] Include cancelled groups. By
+     * default, cancelled groups are excluded.
+     * @param membershipChanges [optional] Include groups whose members have
+     * changed. By default, changes to group memberships are not taken into
+     * consideration.
+     * @param fetch [optional] A comma-separated list of any additional
+     * attributes or references to fetch.
+     *
+     * @return The modified groups (in groupid order).
+     */</doc>
+                    <request>
+                        <param name="minTxId" style="query" type="xs:long" javaType="long" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="maxTxId" style="query" type="xs:long" javaType="long" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="groupids" style="query" type="xs:string" javaType="java.util.List" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="includeCancelled" style="query" type="xs:boolean" javaType="boolean" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="membershipChanges" style="query" type="xs:boolean" javaType="boolean" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="fetch" style="query" type="xs:string" javaType="java.util.List" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                    </request>
+                    <response>
+                        <representation mediaType="application/xml"/>
+                        <representation mediaType="application/json"/>
+                        <representation mediaType="text/xml"/>
+                        <representation mediaType="text/x-json"/>
+                    </response>
+                </method>
+            </resource>
             <resource path="search">
                 <method id="search" name="GET" resultField="groups:java.util.List&lt;IbisGroup&gt;">
                     <doc>
@@ -585,6 +659,63 @@
                     </response>
                 </method>
             </resource>
+            <resource path="modified-insts">
+                <method id="modifiedInsts" name="GET" resultField="institutions:java.util.List&lt;IbisInstitution&gt;">
+                    <doc>
+    /**
+     * Find all institutions modified between the specified pair of
+     * transactions.
+     * &lt;p&gt;
+     * The transaction IDs specified should be the IDs from two different
+     * requests for the last (most recent) transaction ID, made at different
+     * times, that returned different values, indicating that some Lookup
+     * data was modified in the period between the two requests. This method
+     * then determines which (if any) institutions were affected.
+     * &lt;p&gt;
+     * By default, only a few basic details about each institution are
+     * returned, but the optional &lt;code&gt;fetch&lt;/code&gt; parameter may be used
+     * to fetch additional attributes or references.
+     * &lt;p&gt;
+     * NOTE: All data returned reflects the latest available data about each
+     * institution. It is not possible to query for old data, or more
+     * detailed information about the specific changes made.
+     *
+     * @param minTxId [required] Include modifications made in transactions
+     * after (but not including) this one.
+     * @param maxTxId [required] Include modifications made in transactions
+     * up to and including this one.
+     * @param instids [optional] Only include institutions with instids in
+     * this list. By default, all modified institutions will be included.
+     * @param includeCancelled  [optional] Include cancelled institutions. By
+     * default, cancelled institutions are excluded.
+     * @param contactRowChanges [optional] Include institutions whose contact
+     * rows have changed. By default, changes to institution contact rows are
+     * not taken into consideration.
+     * @param membershipChanges [optional] Include institutions whose members
+     * have changed. By default, changes to institutional memberships are not
+     * taken into consideration.
+     * @param fetch [optional] A comma-separated list of any additional
+     * attributes or references to fetch.
+     *
+     * @return The modified institutions (in instid order).
+     */</doc>
+                    <request>
+                        <param name="minTxId" style="query" type="xs:long" javaType="long" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="maxTxId" style="query" type="xs:long" javaType="long" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="instids" style="query" type="xs:string" javaType="java.util.List" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="includeCancelled" style="query" type="xs:boolean" javaType="boolean" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="contactRowChanges" style="query" type="xs:boolean" javaType="boolean" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="membershipChanges" style="query" type="xs:boolean" javaType="boolean" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="fetch" style="query" type="xs:string" javaType="java.util.List" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                    </request>
+                    <response>
+                        <representation mediaType="application/xml"/>
+                        <representation mediaType="application/json"/>
+                        <representation mediaType="text/xml"/>
+                        <representation mediaType="text/x-json"/>
+                    </response>
+                </method>
+            </resource>
             <resource path="search">
                 <method id="search" name="GET" resultField="institutions:java.util.List&lt;IbisInstitution&gt;">
                     <doc>
@@ -1190,6 +1321,64 @@
                     </response>
                 </method>
             </resource>
+            <resource path="modified-people">
+                <method id="modifiedPeople" name="GET" resultField="people:java.util.List&lt;IbisPerson&gt;">
+                    <doc>
+    /**
+     * Find all people modified between the specified pair of transactions.
+     * &lt;p&gt;
+     * The transaction IDs specified should be the IDs from two different
+     * requests for the last (most recent) transaction ID, made at different
+     * times, that returned different values, indicating that some Lookup
+     * data was modified in the period between the two requests. This method
+     * then determines which (if any) people were affected.
+     * &lt;p&gt;
+     * By default, only a few basic details about each person are returned,
+     * but the optional &lt;code&gt;fetch&lt;/code&gt; parameter may be used to fetch
+     * additional attributes or references.
+     * &lt;p&gt;
+     * NOTE: All data returned reflects the latest available data about each
+     * person. It is not possible to query for old data, or more detailed
+     * information about the specific changes made.
+     *
+     * @param minTxId [required] Include modifications made in transactions
+     * after (but not including) this one.
+     * @param maxTxId [required] Include modifications made in transactions
+     * up to and including this one.
+     * @param crsids [optional] Only include people with identifiers in this
+     * list. By default, all modified people will be included.
+     * @param includeCancelled  [optional] Include cancelled people (people
+     * who are no longer members of the University). By default, cancelled
+     * people are excluded.
+     * @param membershipChanges [optional] Include people whose group or
+     * institutional memberships have changed. By default, only people whose
+     * attributes have been directly modified are included.
+     * @param instNameChanges [optional] Include people who are members of
+     * instituions whose names have changed. This will also cause people
+     * whose group or institutional memberships have changed to be included.
+     * By default, changes to institution names do not propagate to people.
+     * @param fetch [optional] A comma-separated list of any additional
+     * attributes or references to fetch.
+     *
+     * @return The modified people (in identifier order).
+     */</doc>
+                    <request>
+                        <param name="minTxId" style="query" type="xs:long" javaType="long" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="maxTxId" style="query" type="xs:long" javaType="long" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="crsids" style="query" type="xs:string" javaType="java.util.List" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="includeCancelled" style="query" type="xs:boolean" javaType="boolean" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="membershipChanges" style="query" type="xs:boolean" javaType="boolean" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="instNameChanges" style="query" type="xs:boolean" javaType="boolean" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                        <param name="fetch" style="query" type="xs:string" javaType="java.util.List" xmlns:xs="http://www.w3.org/2001/XMLSchema"/>
+                    </request>
+                    <response>
+                        <representation mediaType="application/xml"/>
+                        <representation mediaType="application/json"/>
+                        <representation mediaType="text/xml"/>
+                        <representation mediaType="text/x-json"/>
+                    </response>
+                </method>
+            </resource>
             <resource path="search">
                 <method id="search" name="GET" resultField="people:java.util.List&lt;IbisPerson&gt;">
                     <doc>
diff --git a/src/generate-client-methods.py b/src/generate-client-methods.py
index cf6df94..e10e1fa 100755
--- a/src/generate-client-methods.py
+++ b/src/generate-client-methods.py
@@ -532,12 +532,14 @@ def generate_java_method(method):
         path = re.sub("[{][^}]+[}]", "%%%d$s" % param_number, path, 1)
         param_number += 1
 
-    # Final method result (only int and boolean value fields need to be
+    # Final method result (only boolean, int and long value fields need to be
     # coerced into the required type)
     if method.result_type == "boolean":
         result = "Boolean.parseBoolean(result.value)"
     elif method.result_type == "int":
         result = "Integer.parseInt(result.value)"
+    elif method.result_type == "long":
+        result = "Long.parseLong(result.value)"
     else:
         result = "result.%s" % method.result_field
 
@@ -873,12 +875,14 @@ def generate_python_method(cls, method):
     # Method path - replace any placeholders with Python format specifiers
     path = re.sub("[{]([^}]+)[}]", "%(\\1)s", method.path)
 
-    # Final method result (only int and boolean value fields need to be
-    # coerced into the required type)
+    # Final method result (only boolean, int and long value fields need to
+    # be coerced into the required type)
     if method.result_type == "boolean":
         result = "result.value and result.value.lower() == \"true\""
     elif method.result_type == "int":
         result = "int(result.value)"
+    elif method.result_type == "long":
+        result = "int(result.value)" # int works in Python 2 and 3
     else:
         result = "result.%s" % method.result_field
 
@@ -1138,6 +1142,8 @@ def generate_php_method(method):
         result = 'strcasecmp($result->value, "true") == 0'
     elif method.result_type == "int":
         result = "intval($result->value)"
+    elif method.result_type == "long":
+        result = "intval($result->value)" # PHP doesn't have longval()
     else:
         result = "$result->%s" % method.result_field.replace(".", "->")
 
diff --git a/src/java/uk/ac/cam/ucs/ibis/methods/GroupMethods.java b/src/java/uk/ac/cam/ucs/ibis/methods/GroupMethods.java
index 726e282..be22165 100644
--- a/src/java/uk/ac/cam/ucs/ibis/methods/GroupMethods.java
+++ b/src/java/uk/ac/cam/ucs/ibis/methods/GroupMethods.java
@@ -178,6 +178,67 @@ public class GroupMethods
         return result.groups;
     }
 
+    /**
+     * Find all groups modified between the specified pair of transactions.
+     * <p>
+     * The transaction IDs specified should be the IDs from two different
+     * requests for the last (most recent) transaction ID, made at different
+     * times, that returned different values, indicating that some Lookup
+     * data was modified in the period between the two requests. This method
+     * then determines which (if any) groups were affected.
+     * <p>
+     * By default, only a few basic details about each group are returned,
+     * but the optional <code>fetch</code> parameter may be used to fetch
+     * additional attributes or references.
+     * <p>
+     * NOTE: All data returned reflects the latest available data about each
+     * group. It is not possible to query for old data, or more detailed
+     * information about the specific changes made.
+     * <p>
+     * <code style="background-color: #eec;">[ HTTP: GET /api/v1/group/modified-groups?minTxId=...&maxTxId=... ]</code>
+     *
+     * @param minTxId [required] Include modifications made in transactions
+     * after (but not including) this one.
+     * @param maxTxId [required] Include modifications made in transactions
+     * up to and including this one.
+     * @param groupids [optional] Only include groups with IDs or names in
+     * this list. By default, all modified groups will be included.
+     * @param includeCancelled  [optional] Include cancelled groups. By
+     * default, cancelled groups are excluded.
+     * @param membershipChanges [optional] Include groups whose members have
+     * changed. By default, changes to group memberships are not taken into
+     * consideration.
+     * @param fetch [optional] A comma-separated list of any additional
+     * attributes or references to fetch.
+     *
+     * @return The modified groups (in groupid order).
+     */
+    public java.util.List<IbisGroup> modifiedGroups(long    minTxId,
+                                                    long    maxTxId,
+                                                    String  groupids,
+                                                    boolean includeCancelled,
+                                                    boolean membershipChanges,
+                                                    String  fetch)
+        throws IbisException, IOException, JAXBException
+    {
+        String[] pathParams = {  };
+        Object[] queryParams = { "minTxId", minTxId,
+                                 "maxTxId", maxTxId,
+                                 "groupids", groupids,
+                                 "includeCancelled", includeCancelled,
+                                 "membershipChanges", membershipChanges,
+                                 "fetch", fetch };
+        Object[] formParams = {  };
+        IbisResult result = conn.invokeMethod(Method.GET,
+                                              "api/v1/group/modified-groups",
+                                              pathParams,
+                                              queryParams,
+                                              formParams);
+        if (result.error != null)
+            throw new IbisException(result.error);
+        return result.groups;
+    }
+
     /**
      * Search for groups using a free text query string. This is the same
      * search function that is used in the Lookup web application.
diff --git a/src/java/uk/ac/cam/ucs/ibis/methods/IbisMethods.java b/src/java/uk/ac/cam/ucs/ibis/methods/IbisMethods.java
index ca61517..b3a678b 100644
--- a/src/java/uk/ac/cam/ucs/ibis/methods/IbisMethods.java
+++ b/src/java/uk/ac/cam/ucs/ibis/methods/IbisMethods.java
@@ -50,6 +50,34 @@ public class IbisMethods
         this.conn = conn;
     }
 
+    /**
+     * Get the ID of the last (most recent) transaction.
+     * <p>
+     * A transaction represents an edit made to data in Lookup. Each
+     * transaction is assigned a unique, sequential, numeric ID. Thus
+     * this last transaction ID will increase each time some data in
+     * Lookup is changed.
+     * <p>
+     * <code style="background-color: #eec;">[ HTTP: GET /api/v1/last-transaction ]</code>
+     *
+     * @return The ID of the latest transaction.
+     */
+    public long getLastTransactionId()
+        throws IbisException, IOException, JAXBException
+    {
+        String[] pathParams = {  };
+        Object[] queryParams = {  };
+        Object[] formParams = {  };
+        IbisResult result = conn.invokeMethod(Method.GET,
+                                              "api/v1/last-transaction",
+                                              pathParams,
+                                              queryParams,
+                                              formParams);
+        if (result.error != null)
+            throw new IbisException(result.error);
+        return Long.parseLong(result.value);
+    }
+
     /**
      * Get the current API version number.
      * <p>
diff --git a/src/java/uk/ac/cam/ucs/ibis/methods/InstitutionMethods.java b/src/java/uk/ac/cam/ucs/ibis/methods/InstitutionMethods.java
index 1657ddf..03cdfcb 100644
--- a/src/java/uk/ac/cam/ucs/ibis/methods/InstitutionMethods.java
+++ b/src/java/uk/ac/cam/ucs/ibis/methods/InstitutionMethods.java
@@ -208,6 +208,73 @@ public class InstitutionMethods
         return result.institutions;
     }
 
+    /**
+     * Find all institutions modified between the specified pair of
+     * transactions.
+     * <p>
+     * The transaction IDs specified should be the IDs from two different
+     * requests for the last (most recent) transaction ID, made at different
+     * times, that returned different values, indicating that some Lookup
+     * data was modified in the period between the two requests. This method
+     * then determines which (if any) institutions were affected.
+     * <p>
+     * By default, only a few basic details about each institution are
+     * returned, but the optional <code>fetch</code> parameter may be used
+     * to fetch additional attributes or references.
+     * <p>
+     * NOTE: All data returned reflects the latest available data about each
+     * institution. It is not possible to query for old data, or more
+     * detailed information about the specific changes made.
+     * <p>
+     * <code style="background-color: #eec;">[ HTTP: GET /api/v1/inst/modified-insts?minTxId=...&maxTxId=... ]</code>
+     *
+     * @param minTxId [required] Include modifications made in transactions
+     * after (but not including) this one.
+     * @param maxTxId [required] Include modifications made in transactions
+     * up to and including this one.
+     * @param instids [optional] Only include institutions with instids in
+     * this list. By default, all modified institutions will be included.
+     * @param includeCancelled  [optional] Include cancelled institutions. By
+     * default, cancelled institutions are excluded.
+     * @param contactRowChanges [optional] Include institutions whose contact
+     * rows have changed. By default, changes to institution contact rows are
+     * not taken into consideration.
+     * @param membershipChanges [optional] Include institutions whose members
+     * have changed. By default, changes to institutional memberships are not
+     * taken into consideration.
+     * @param fetch [optional] A comma-separated list of any additional
+     * attributes or references to fetch.
+     *
+     * @return The modified institutions (in instid order).
+     */
+    public java.util.List<IbisInstitution> modifiedInsts(long       minTxId,
+                                                         long       maxTxId,
+                                                         String     instids,
+                                                         boolean    includeCancelled,
+                                                         boolean    contactRowChanges,
+                                                         boolean    membershipChanges,
+                                                         String     fetch)
+        throws IbisException, IOException, JAXBException
+    {
+        String[] pathParams = {  };
+        Object[] queryParams = { "minTxId", minTxId,
+                                 "maxTxId", maxTxId,
+                                 "instids", instids,
+                                 "includeCancelled", includeCancelled,
+                                 "contactRowChanges", contactRowChanges,
+                                 "membershipChanges", membershipChanges,
+                                 "fetch", fetch };
+        Object[] formParams = {  };
+        IbisResult result = conn.invokeMethod(Method.GET,
+                                              "api/v1/inst/modified-insts",
+                                              pathParams,
+                                              queryParams,
+                                              formParams);
+        if (result.error != null)
+            throw new IbisException(result.error);
+        return result.institutions;
+    }
+
     /**
      * Search for institutions using a free text query string. This is the
      * same search function that is used in the Lookup web application.
diff --git a/src/java/uk/ac/cam/ucs/ibis/methods/PersonMethods.java b/src/java/uk/ac/cam/ucs/ibis/methods/PersonMethods.java
index 8414449..09c1827 100644
--- a/src/java/uk/ac/cam/ucs/ibis/methods/PersonMethods.java
+++ b/src/java/uk/ac/cam/ucs/ibis/methods/PersonMethods.java
@@ -273,6 +273,74 @@ public class PersonMethods
         return result.people;
     }
 
+    /**
+     * Find all people modified between the specified pair of transactions.
+     * <p>
+     * The transaction IDs specified should be the IDs from two different
+     * requests for the last (most recent) transaction ID, made at different
+     * times, that returned different values, indicating that some Lookup
+     * data was modified in the period between the two requests. This method
+     * then determines which (if any) people were affected.
+     * <p>
+     * By default, only a few basic details about each person are returned,
+     * but the optional <code>fetch</code> parameter may be used to fetch
+     * additional attributes or references.
+     * <p>
+     * NOTE: All data returned reflects the latest available data about each
+     * person. It is not possible to query for old data, or more detailed
+     * information about the specific changes made.
+     * <p>
+     * <code style="background-color: #eec;">[ HTTP: GET /api/v1/person/modified-people?minTxId=...&maxTxId=... ]</code>
+     *
+     * @param minTxId [required] Include modifications made in transactions
+     * after (but not including) this one.
+     * @param maxTxId [required] Include modifications made in transactions
+     * up to and including this one.
+     * @param crsids [optional] Only include people with identifiers in this
+     * list. By default, all modified people will be included.
+     * @param includeCancelled  [optional] Include cancelled people (people
+     * who are no longer members of the University). By default, cancelled
+     * people are excluded.
+     * @param membershipChanges [optional] Include people whose group or
+     * institutional memberships have changed. By default, only people whose
+     * attributes have been directly modified are included.
+     * @param instNameChanges [optional] Include people who are members of
+     * instituions whose names have changed. This will also cause people
+     * whose group or institutional memberships have changed to be included.
+     * By default, changes to institution names do not propagate to people.
+     * @param fetch [optional] A comma-separated list of any additional
+     * attributes or references to fetch.
+     *
+     * @return The modified people (in identifier order).
+     */
+    public java.util.List<IbisPerson> modifiedPeople(long       minTxId,
+                                                     long       maxTxId,
+                                                     String     crsids,
+                                                     boolean    includeCancelled,
+                                                     boolean    membershipChanges,
+                                                     boolean    instNameChanges,
+                                                     String     fetch)
+        throws IbisException, IOException, JAXBException
+    {
+        String[] pathParams = {  };
+        Object[] queryParams = { "minTxId", minTxId,
+                                 "maxTxId", maxTxId,
+                                 "crsids", crsids,
+                                 "includeCancelled", includeCancelled,
+                                 "membershipChanges", membershipChanges,
+                                 "instNameChanges", instNameChanges,
+                                 "fetch", fetch };
+        Object[] formParams = {  };
+        IbisResult result = conn.invokeMethod(Method.GET,
+                                              "api/v1/person/modified-people",
+                                              pathParams,
+                                              queryParams,
+                                              formParams);
+        if (result.error != null)
+            throw new IbisException(result.error);
+        return result.people;
+    }
+
     /**
      * Search for people using a free text query string. This is the same
      * search function that is used in the Lookup web application.
diff --git a/src/php/ibisclient/methods/GroupMethods.php b/src/php/ibisclient/methods/GroupMethods.php
index 5e5b339..631e4e6 100644
--- a/src/php/ibisclient/methods/GroupMethods.php
+++ b/src/php/ibisclient/methods/GroupMethods.php
@@ -178,6 +178,66 @@ class GroupMethods
         return $result->groups;
     }
 
+    /**
+     * Find all groups modified between the specified pair of transactions.
+     *
+     * The transaction IDs specified should be the IDs from two different
+     * requests for the last (most recent) transaction ID, made at different
+     * times, that returned different values, indicating that some Lookup
+     * data was modified in the period between the two requests. This method
+     * then determines which (if any) groups were affected.
+     *
+     * By default, only a few basic details about each group are returned,
+     * but the optional ``fetch`` parameter may be used to fetch
+     * additional attributes or references.
+     *
+     * NOTE: All data returned reflects the latest available data about each
+     * group. It is not possible to query for old data, or more detailed
+     * information about the specific changes made.
+     *
+     * ``[ HTTP: GET /api/v1/group/modified-groups?minTxId=...&maxTxId=... ]``
+     *
+     * @param long $minTxId [required] Include modifications made in transactions
+     * after (but not including) this one.
+     * @param long $maxTxId [required] Include modifications made in transactions
+     * up to and including this one.
+     * @param string $groupids [optional] Only include groups with IDs or names in
+     * this list. By default, all modified groups will be included.
+     * @param boolean $includeCancelled  [optional] Include cancelled groups. By
+     * default, cancelled groups are excluded.
+     * @param boolean $membershipChanges [optional] Include groups whose members have
+     * changed. By default, changes to group memberships are not taken into
+     * consideration.
+     * @param string $fetch [optional] A comma-separated list of any additional
+     * attributes or references to fetch.
+     *
+     * @return IbisGroup[] The modified groups (in groupid order).
+     */
+    public function modifiedGroups($minTxId,
+                                   $maxTxId,
+                                   $groupids=null,
+                                   $includeCancelled=null,
+                                   $membershipChanges=null,
+                                   $fetch=null)
+    {
+        $pathParams = array();
+        $queryParams = array("minTxId"           => $minTxId,
+                             "maxTxId"           => $maxTxId,
+                             "groupids"          => $groupids,
+                             "includeCancelled"  => $includeCancelled,
+                             "membershipChanges" => $membershipChanges,
+                             "fetch"             => $fetch);
+        $formParams = array();
+        $result = $this->conn->invokeMethod("GET",
+                                            'api/v1/group/modified-groups',
+                                            $pathParams,
+                                            $queryParams,
+                                            $formParams);
+        if (isset($result->error))
+            throw new IbisException($result->error);
+        return $result->groups;
+    }
+
     /**
      * Search for groups using a free text query string. This is the same
      * search function that is used in the Lookup web application.
diff --git a/src/php/ibisclient/methods/IbisMethods.php b/src/php/ibisclient/methods/IbisMethods.php
index 783e455..0595fd0 100644
--- a/src/php/ibisclient/methods/IbisMethods.php
+++ b/src/php/ibisclient/methods/IbisMethods.php
@@ -43,6 +43,33 @@ class IbisMethods
         $this->conn = $conn;
     }
 
+    /**
+     * Get the ID of the last (most recent) transaction.
+     *
+     * A transaction represents an edit made to data in Lookup. Each
+     * transaction is assigned a unique, sequential, numeric ID. Thus
+     * this last transaction ID will increase each time some data in
+     * Lookup is changed.
+     *
+     * ``[ HTTP: GET /api/v1/last-transaction ]``
+     *
+     * @return long The ID of the latest transaction.
+     */
+    public function getLastTransactionId()
+    {
+        $pathParams = array();
+        $queryParams = array();
+        $formParams = array();
+        $result = $this->conn->invokeMethod("GET",
+                                            'api/v1/last-transaction',
+                                            $pathParams,
+                                            $queryParams,
+                                            $formParams);
+        if (isset($result->error))
+            throw new IbisException($result->error);
+        return intval($result->value);
+    }
+
     /**
      * Get the current API version number.
      *
diff --git a/src/php/ibisclient/methods/InstitutionMethods.php b/src/php/ibisclient/methods/InstitutionMethods.php
index f5122a7..7e0032b 100644
--- a/src/php/ibisclient/methods/InstitutionMethods.php
+++ b/src/php/ibisclient/methods/InstitutionMethods.php
@@ -203,6 +203,72 @@ class InstitutionMethods
         return $result->institutions;
     }
 
+    /**
+     * Find all institutions modified between the specified pair of
+     * transactions.
+     *
+     * The transaction IDs specified should be the IDs from two different
+     * requests for the last (most recent) transaction ID, made at different
+     * times, that returned different values, indicating that some Lookup
+     * data was modified in the period between the two requests. This method
+     * then determines which (if any) institutions were affected.
+     *
+     * By default, only a few basic details about each institution are
+     * returned, but the optional ``fetch`` parameter may be used
+     * to fetch additional attributes or references.
+     *
+     * NOTE: All data returned reflects the latest available data about each
+     * institution. It is not possible to query for old data, or more
+     * detailed information about the specific changes made.
+     *
+     * ``[ HTTP: GET /api/v1/inst/modified-insts?minTxId=...&maxTxId=... ]``
+     *
+     * @param long $minTxId [required] Include modifications made in transactions
+     * after (but not including) this one.
+     * @param long $maxTxId [required] Include modifications made in transactions
+     * up to and including this one.
+     * @param string $instids [optional] Only include institutions with instids in
+     * this list. By default, all modified institutions will be included.
+     * @param boolean $includeCancelled  [optional] Include cancelled institutions. By
+     * default, cancelled institutions are excluded.
+     * @param boolean $contactRowChanges [optional] Include institutions whose contact
+     * rows have changed. By default, changes to institution contact rows are
+     * not taken into consideration.
+     * @param boolean $membershipChanges [optional] Include institutions whose members
+     * have changed. By default, changes to institutional memberships are not
+     * taken into consideration.
+     * @param string $fetch [optional] A comma-separated list of any additional
+     * attributes or references to fetch.
+     *
+     * @return IbisInstitution[] The modified institutions (in instid order).
+     */
+    public function modifiedInsts($minTxId,
+                                  $maxTxId,
+                                  $instids=null,
+                                  $includeCancelled=null,
+                                  $contactRowChanges=null,
+                                  $membershipChanges=null,
+                                  $fetch=null)
+    {
+        $pathParams = array();
+        $queryParams = array("minTxId"           => $minTxId,
+                             "maxTxId"           => $maxTxId,
+                             "instids"           => $instids,
+                             "includeCancelled"  => $includeCancelled,
+                             "contactRowChanges" => $contactRowChanges,
+                             "membershipChanges" => $membershipChanges,
+                             "fetch"             => $fetch);
+        $formParams = array();
+        $result = $this->conn->invokeMethod("GET",
+                                            'api/v1/inst/modified-insts',
+                                            $pathParams,
+                                            $queryParams,
+                                            $formParams);
+        if (isset($result->error))
+            throw new IbisException($result->error);
+        return $result->institutions;
+    }
+
     /**
      * Search for institutions using a free text query string. This is the
      * same search function that is used in the Lookup web application.
diff --git a/src/php/ibisclient/methods/PersonMethods.php b/src/php/ibisclient/methods/PersonMethods.php
index 8f95428..7dc1e38 100644
--- a/src/php/ibisclient/methods/PersonMethods.php
+++ b/src/php/ibisclient/methods/PersonMethods.php
@@ -251,6 +251,73 @@ class PersonMethods
         return $result->people;
     }
 
+    /**
+     * Find all people modified between the specified pair of transactions.
+     *
+     * The transaction IDs specified should be the IDs from two different
+     * requests for the last (most recent) transaction ID, made at different
+     * times, that returned different values, indicating that some Lookup
+     * data was modified in the period between the two requests. This method
+     * then determines which (if any) people were affected.
+     *
+     * By default, only a few basic details about each person are returned,
+     * but the optional ``fetch`` parameter may be used to fetch
+     * additional attributes or references.
+     *
+     * NOTE: All data returned reflects the latest available data about each
+     * person. It is not possible to query for old data, or more detailed
+     * information about the specific changes made.
+     *
+     * ``[ HTTP: GET /api/v1/person/modified-people?minTxId=...&maxTxId=... ]``
+     *
+     * @param long $minTxId [required] Include modifications made in transactions
+     * after (but not including) this one.
+     * @param long $maxTxId [required] Include modifications made in transactions
+     * up to and including this one.
+     * @param string $crsids [optional] Only include people with identifiers in this
+     * list. By default, all modified people will be included.
+     * @param boolean $includeCancelled  [optional] Include cancelled people (people
+     * who are no longer members of the University). By default, cancelled
+     * people are excluded.
+     * @param boolean $membershipChanges [optional] Include people whose group or
+     * institutional memberships have changed. By default, only people whose
+     * attributes have been directly modified are included.
+     * @param boolean $instNameChanges [optional] Include people who are members of
+     * instituions whose names have changed. This will also cause people
+     * whose group or institutional memberships have changed to be included.
+     * By default, changes to institution names do not propagate to people.
+     * @param string $fetch [optional] A comma-separated list of any additional
+     * attributes or references to fetch.
+     *
+     * @return IbisPerson[] The modified people (in identifier order).
+     */
+    public function modifiedPeople($minTxId,
+                                   $maxTxId,
+                                   $crsids=null,
+                                   $includeCancelled=null,
+                                   $membershipChanges=null,
+                                   $instNameChanges=null,
+                                   $fetch=null)
+    {
+        $pathParams = array();
+        $queryParams = array("minTxId"           => $minTxId,
+                             "maxTxId"           => $maxTxId,
+                             "crsids"            => $crsids,
+                             "includeCancelled"  => $includeCancelled,
+                             "membershipChanges" => $membershipChanges,
+                             "instNameChanges"   => $instNameChanges,
+                             "fetch"             => $fetch);
+        $formParams = array();
+        $result = $this->conn->invokeMethod("GET",
+                                            'api/v1/person/modified-people',
+                                            $pathParams,
+                                            $queryParams,
+                                            $formParams);
+        if (isset($result->error))
+            throw new IbisException($result->error);
+        return $result->people;
+    }
+
     /**
      * Search for people using a free text query string. This is the same
      * search function that is used in the Lookup web application.
diff --git a/src/php/test/UnitTests.php b/src/php/test/UnitTests.php
index 648401a..e88c717 100644
--- a/src/php/test/UnitTests.php
+++ b/src/php/test/UnitTests.php
@@ -18,31 +18,34 @@ You should have received a copy of the GNU Lesser General Public License
 along with this library.  If not, see <http://www.gnu.org/licenses/>.
 */
 
-require_once 'PHPUnit/Autoload.php';
-
 require_once dirname(__FILE__) . "/../ibisclient/client/IbisClientConnection.php";
 require_once dirname(__FILE__) . "/../ibisclient/client/IbisException.php";
 require_once dirname(__FILE__) . "/../ibisclient/methods/GroupMethods.php";
+require_once dirname(__FILE__) . "/../ibisclient/methods/IbisMethods.php";
 require_once dirname(__FILE__) . "/../ibisclient/methods/InstitutionMethods.php";
 require_once dirname(__FILE__) . "/../ibisclient/methods/PersonMethods.php";
 
-class UnitTests extends PHPUnit_Framework_TestCase
+use PHPUnit\Framework\TestCase;
+
+class UnitTests extends TestCase
 {
     private static $localConnection = false;
     private static $runEditTests = false;
     private static $initialised = false;
     private static $conn = null;
+    private static $m = null;
     private static $pm = null;
     private static $im = null;
     private static $gm = null;
 
-    public function setUp()
+    public function setUp(): void
     {
         if (!UnitTests::$initialised)
         {
             UnitTests::$conn = UnitTests::$localConnection ?
                                IbisClientConnection::createLocalConnection() :
                                IbisClientConnection::createTestConnection();
+            UnitTests::$m = new IbisMethods(UnitTests::$conn);
             UnitTests::$pm = new PersonMethods(UnitTests::$conn);
             UnitTests::$im = new InstitutionMethods(UnitTests::$conn);
             UnitTests::$gm = new GroupMethods(UnitTests::$conn);
@@ -52,6 +55,23 @@ class UnitTests extends PHPUnit_Framework_TestCase
         print(" " . $this->getName() . "()\n");
     }
 
+    // --------------------------------------------------------------------
+    // Ibis tests.
+    // --------------------------------------------------------------------
+
+    public function testGetVersion()
+    {
+        $version = UnitTests::$m->getVersion();
+        $this->assertNotNull($version);
+        $this->assertEquals(1, preg_match("/^[0-9]+[.][0-9]+\$/", $version));
+    }
+
+    public function testGetLastTransactionId()
+    {
+        $lastTransactionId = UnitTests::$m->getLastTransactionId();
+        $this->assertTrue($lastTransactionId > 900000);
+    }
+
     // --------------------------------------------------------------------
     // Person tests.
     // --------------------------------------------------------------------
@@ -64,6 +84,24 @@ class UnitTests extends PHPUnit_Framework_TestCase
         $this->assertEquals("displayName", $schemes[0]->schemeid);
     }
 
+    public function testAllPeople()
+    {
+        $people = UnitTests::$pm->allPeople(false, null, 10, null);
+
+        $this->assertEquals(10, sizeof($people));
+        $this->assertEquals("aa", substr($people[0]->identifier->value, 0, 2));
+
+        $people = UnitTests::$pm->allPeople(false, "dar17", 10, null);
+        $this->assertEquals(10, sizeof($people));
+        $this->assertTrue(strcmp($people[0]->identifier->value, "dar17") > 0);
+
+        $id8 = $people[8]->identifier->value;
+        $id9 = $people[9]->identifier->value;
+        $people = UnitTests::$pm->allPeople(false, $id8, 10, null);
+        $this->assertEquals(10, sizeof($people));
+        $this->assertEquals($id9, $people[0]->identifier->value);
+    }
+
     public function testNoSuchPerson()
     {
         $person = UnitTests::$pm->getPerson("crsid", "dar1734toolong");
@@ -166,6 +204,36 @@ class UnitTests extends PHPUnit_Framework_TestCase
         $this->assertTrue($count > 10);
     }
 
+    public function testPersonLqlSearch()
+    {
+        $people = UnitTests::$pm->search("person: in inst(uis) and surname=Rasheed", false, false,
+                                         null, null, 0, 100, null, "title");
+        $this->assertEquals(1, sizeof($people));
+        $this->assertEquals("dar17", $people[0]->identifier->value);
+        $this->assertEquals("Database administrator and developer", $people[0]->attributes[0]->value);
+
+        $people = UnitTests::$pm->search("person: dar54", false, false,
+                                         null, null, 0, 100, null, null);
+        $this->assertEquals(0, sizeof($people));
+
+        $people = UnitTests::$pm->search("person: dar54", false, true,
+                                         null, null, 0, 100, null, null);
+        $this->assertEquals(1, sizeof($people));
+        $this->assertEquals("dar54", $people[0]->identifier->value);
+    }
+
+    public function testPersonLqlSearchCount()
+    {
+        $count = UnitTests::$pm->searchCount("person: dar17", false, false, null, null);
+        $this->assertEquals(1, $count);
+
+        $count = UnitTests::$pm->searchCount("person: dar54", false, false, null, null);
+        $this->assertEquals(0, $count);
+
+        $count = UnitTests::$pm->searchCount("person: dar54", false, true, null, null);
+        $this->assertEquals(1, $count);
+    }
+
     public function testIsPersonMemberOfInst()
     {
         $this->assertTrue(UnitTests::$pm->isMemberOfInst("crsid", "dar17", "UIS"));
@@ -264,7 +332,12 @@ class UnitTests extends PHPUnit_Framework_TestCase
 
     public function testPersonEdit()
     {
-        if (!UnitTests::$runEditTests) return;
+        if (!UnitTests::$runEditTests)
+        {
+            $this->assertTrue(true); // Keep PHPUnit happy
+            return;
+        }
+
         $ex = null;
         try
         {
@@ -377,7 +450,12 @@ class UnitTests extends PHPUnit_Framework_TestCase
 
     public function testPersonEditImage()
     {
-        if (!UnitTests::$runEditTests) return;
+        if (!UnitTests::$runEditTests) 
+        {
+            $this->assertTrue(true); // Keep PHPUnit happy
+            return;
+        }
+
         $ex = null;
         try
         {
@@ -453,6 +531,32 @@ class UnitTests extends PHPUnit_Framework_TestCase
         if ($ex) throw $ex;
     }
 
+    public function testModifiedPeople()
+    {
+        // Note: transactions 2..1047 are the same on Lookup and lookup-test
+        $people = UnitTests::$pm->modifiedPeople(937, 938, null, false, false, false, null);
+        $this->assertEquals(1, sizeof($people));
+        $this->assertEquals("v4500", $people[0]->identifier->value);
+
+        $people = UnitTests::$pm->modifiedPeople(752, 753, null, false, false, false, null);
+        $this->assertEquals(0, sizeof($people));
+
+        $people = UnitTests::$pm->modifiedPeople(752, 753, null, true, false, false, null);
+        $this->assertEquals(2, sizeof($people));
+        $this->assertEquals("cr10001", $people[0]->identifier->value);
+        $this->assertEquals("gar34", $people[1]->identifier->value);
+
+        $people = UnitTests::$pm->modifiedPeople(752, 753, "cr10001", false, false, false, null);
+        $this->assertEquals(0, sizeof($people));
+
+        $people = UnitTests::$pm->modifiedPeople(752, 753, "cr10001", true, false, false, null);
+        $this->assertEquals(1, sizeof($people));
+        $this->assertEquals("cr10001", $people[0]->identifier->value);
+
+        $people = UnitTests::$pm->modifiedPeople(752, 753, "cr10002", true, false, false, null);
+        $this->assertEquals(0, sizeof($people));
+    }
+
     // --------------------------------------------------------------------
     // Institution tests.
     // --------------------------------------------------------------------
@@ -656,6 +760,31 @@ class UnitTests extends PHPUnit_Framework_TestCase
         $this->assertTrue($count > 5);
     }
 
+    public function testInstLqlSearch()
+    {
+        $insts = UnitTests::$im->search("inst: parent of (uistest)", false, false,
+                                        null, 0, 100, null, null);
+        $this->assertEquals("UIS", $insts[0]->instid);
+
+        $insts = UnitTests::$im->search("inst: address ~ CB3 0JG", false, false,
+                                        null, 0, 100, null, "phone_numbers");
+        $this->assertEquals(1, sizeof($insts));
+        $this->assertEquals("GIRTON", $insts[0]->instid);
+        $this->assertEquals("38999", $insts[0]->attributes[0]->value);
+    }
+
+    public function testInstLqlSearchCount()
+    {
+        $count = UnitTests::$im->searchCount("inst: UIS", false, false, null);
+        $this->assertEquals(1, $count);
+
+        $count = UnitTests::$im->searchCount("inst: CS", false, false, null);
+        $this->assertEquals(0, $count);
+
+        $count = UnitTests::$im->searchCount("inst: CS", false, true, null);
+        $this->assertEquals(1, $count);
+    }
+
     public function testGetInstContactRows()
     {
         $inst = UnitTests::$im->getInst("CS", "contact_rows.jdInstid");
@@ -714,7 +843,12 @@ class UnitTests extends PHPUnit_Framework_TestCase
 
     public function testInstEdit()
     {
-        if (!UnitTests::$runEditTests) return;
+        if (!UnitTests::$runEditTests) 
+        {
+            $this->assertTrue(true); // Keep PHPUnit happy
+            return;
+        }
+
         $ex = null;
         try
         {
@@ -805,7 +939,12 @@ class UnitTests extends PHPUnit_Framework_TestCase
 
     public function testInstEditImage()
     {
-        if (!UnitTests::$runEditTests) return;
+        if (!UnitTests::$runEditTests) 
+        {
+            $this->assertTrue(true); // Keep PHPUnit happy
+            return;
+        }
+
         $ex = null;
         try
         {
@@ -869,6 +1008,36 @@ class UnitTests extends PHPUnit_Framework_TestCase
         if ($ex) throw $ex;
     }
 
+    public function testModifiedInsts()
+    {
+        // Note: transactions 2..1047 are the same on Lookup and lookup-test
+        $insts = UnitTests::$im->modifiedInsts(491, 492, null, false, false, false, null);
+
+        $this->assertEquals(1, sizeof($insts));
+        $this->assertEquals("AUT", $insts[0]->instid);
+
+        $insts = UnitTests::$im->modifiedInsts(438, 439, null, false, false, false, null);
+        $this->assertEquals(0, sizeof($insts));
+
+        $insts = UnitTests::$im->modifiedInsts(438, 439, null, true, false, false, null);
+        $this->assertEquals(1, sizeof($insts));
+        $this->assertEquals("SPVSR04", $insts[0]->instid);
+
+        $insts = UnitTests::$im->modifiedInsts(764, 765, "IUSCMED", false, false, false, null);
+        $this->assertEquals(1, sizeof($insts));
+        $this->assertEquals("IUSCMED", $insts[0]->instid);
+
+        $insts = UnitTests::$im->modifiedInsts(764, 765, "IUSCMED2", false, false, false, null);
+        $this->assertEquals(0, sizeof($insts));
+
+        $insts = UnitTests::$im->modifiedInsts(45, 46, null, false, false, false, null);
+        $this->assertEquals(0, sizeof($insts));
+
+        $insts = UnitTests::$im->modifiedInsts(45, 46, null, false, true, false, null);
+        $this->assertEquals(1, sizeof($insts));
+        $this->assertEquals("Clare Hall", $insts[0]->name);
+    }
+
     // --------------------------------------------------------------------
     // Group tests.
     // --------------------------------------------------------------------
@@ -996,9 +1165,45 @@ class UnitTests extends PHPUnit_Framework_TestCase
         $this->assertEquals(6, $count);
     }
 
+    public function testGroupLqlSearch()
+    {
+        $groups = UnitTests::$gm->search("group: title='Editors group for \"UIS\"'",
+                                         false, false, 0, 100, null, null);
+        $this->assertEquals("uis-editors", $groups[0]->name);
+
+        $groups = UnitTests::$gm->search("group: uistest-members", false, false,
+                                         0, 1, null, "all_members");
+        $this->assertEquals(1, sizeof($groups));
+        $this->assertEquals("uistest-members", $groups[0]->name);
+        $this->assertTrue(sizeof($groups[0]->members) > 10);
+        $this->assertEquals("abc123", $groups[0]->members[0]->identifier->value);
+
+        $groups = UnitTests::$gm->search("group: biotec-editors", false, false,
+                                         0, 1, null, null);
+        $this->assertEquals(0, sizeof($groups));
+
+        $groups = UnitTests::$gm->search("group: biotec-editors", false, true,
+                                         0, 1, null, null);
+        $this->assertEquals(1, sizeof($groups));
+    }
+
+    public function testGroupLqlSearchCount()
+    {
+        $count = UnitTests::$gm->searchCount("group: biotec-editors", false, false);
+        $this->assertEquals(0, $count);
+
+        $count = UnitTests::$gm->searchCount("group: biotec-editors", false, true);
+        $this->assertEquals(1, $count);
+    }
+
     public function testEditGroupMembers()
     {
-        if (!UnitTests::$runEditTests) return;
+        if (!UnitTests::$runEditTests) 
+        {
+            $this->assertTrue(true); // Keep PHPUnit happy
+            return;
+        }
+
         $ex = null;
         try
         {
@@ -1061,4 +1266,37 @@ class UnitTests extends PHPUnit_Framework_TestCase
 
         if ($ex) throw $ex;
     }
+
+    public function testModifiedGroups()
+    {
+        // Note: transactions 2..1047 are the same on Lookup and lookup-test
+        $groups = UnitTests::$gm->modifiedGroups(492, 493, null, false, false, null);
+
+        $this->assertEquals(1, sizeof($groups));
+        $this->assertEquals("100426", $groups[0]->groupid);
+        $this->assertEquals("maths-intakes-editors", $groups[0]->name);
+
+        $groups = UnitTests::$gm->modifiedGroups(487, 488, null, false, false, null);
+        $this->assertEquals(0, sizeof($groups));
+
+        $groups = UnitTests::$gm->modifiedGroups(487, 488, null, true, false, null);
+        $this->assertEquals(1, sizeof($groups));
+        $this->assertEquals("100855", $groups[0]->groupid);
+        $this->assertEquals("cstest-foofoo", $groups[0]->name);
+
+        $groups = UnitTests::$gm->modifiedGroups(743, 744, "100259", false, false, null);
+        $this->assertEquals(1, sizeof($groups));
+        $this->assertEquals("100259", $groups[0]->groupid);
+        $this->assertEquals("biol-managers", $groups[0]->name);
+
+        $groups = UnitTests::$gm->modifiedGroups(743, 744, "biol-managers", false, false, null);
+        $this->assertEquals(1, sizeof($groups));
+        $this->assertEquals("100259", $groups[0]->groupid);
+        $this->assertEquals("biol-managers", $groups[0]->name);
+
+        $groups = UnitTests::$gm->modifiedGroups(743, 744, "100260", false, false, null);
+        $this->assertEquals(0, sizeof($groups));
+        $groups = UnitTests::$gm->modifiedGroups(743, 744, "biol-editors", false, false, null);
+        $this->assertEquals(0, sizeof($groups));
+    }
 }
diff --git a/src/python/ibisclient/methods.py b/src/python/ibisclient/methods.py
index 8736859..0aa65df 100644
--- a/src/python/ibisclient/methods.py
+++ b/src/python/ibisclient/methods.py
@@ -36,6 +36,31 @@ class IbisMethods:
     def __init__(self, conn):
         self.conn = conn
 
+    def getLastTransactionId(self):
+        """
+        Get the ID of the last (most recent) transaction.
+
+        A transaction represents an edit made to data in Lookup. Each
+        transaction is assigned a unique, sequential, numeric ID. Thus
+        this last transaction ID will increase each time some data in
+        Lookup is changed.
+
+        ``[ HTTP: GET /api/v1/last-transaction ]``
+
+        **Returns**
+          long
+            The ID of the latest transaction.
+        """
+        path = "api/v1/last-transaction"
+        path_params = {}
+        query_params = {}
+        form_params = {}
+        result = self.conn.invoke_method("GET", path, path_params,
+                                         query_params, form_params)
+        if result.error:
+            raise IbisException(result.error)
+        return int(result.value)
+
     def getVersion(self):
         """
         Get the current API version number.
@@ -208,6 +233,78 @@ class GroupMethods:
             raise IbisException(result.error)
         return result.groups
 
+    def modifiedGroups(self,
+                       minTxId,
+                       maxTxId,
+                       groupids=None,
+                       includeCancelled=None,
+                       membershipChanges=None,
+                       fetch=None):
+        """
+        Find all groups modified between the specified pair of transactions.
+
+        The transaction IDs specified should be the IDs from two different
+        requests for the last (most recent) transaction ID, made at different
+        times, that returned different values, indicating that some Lookup
+        data was modified in the period between the two requests. This method
+        then determines which (if any) groups were affected.
+
+        By default, only a few basic details about each group are returned,
+        but the optional `fetch` parameter may be used to fetch
+        additional attributes or references.
+
+        .. note::
+          All data returned reflects the latest available data about each
+          group. It is not possible to query for old data, or more detailed
+          information about the specific changes made.
+
+        ``[ HTTP: GET /api/v1/group/modified-groups?minTxId=...&maxTxId=... ]``
+
+        **Parameters**
+          `minTxId` : long
+            [required] Include modifications made in transactions
+            after (but not including) this one.
+
+          `maxTxId` : long
+            [required] Include modifications made in transactions
+            up to and including this one.
+
+          `groupids` : str
+            [optional] Only include groups with IDs or names in
+            this list. By default, all modified groups will be included.
+
+          `includeCancelled` : bool
+            [optional] Include cancelled groups. By
+            default, cancelled groups are excluded.
+
+          `membershipChanges` : bool
+            [optional] Include groups whose members have
+            changed. By default, changes to group memberships are not taken into
+            consideration.
+
+          `fetch` : str
+            [optional] A comma-separated list of any additional
+            attributes or references to fetch.
+
+        **Returns**
+          list of :any:`IbisGroup`
+            The modified groups (in groupid order).
+        """
+        path = "api/v1/group/modified-groups"
+        path_params = {}
+        query_params = {"minTxId": minTxId,
+                        "maxTxId": maxTxId,
+                        "groupids": groupids,
+                        "includeCancelled": includeCancelled,
+                        "membershipChanges": membershipChanges,
+                        "fetch": fetch}
+        form_params = {}
+        result = self.conn.invoke_method("GET", path, path_params,
+                                         query_params, form_params)
+        if result.error:
+            raise IbisException(result.error)
+        return result.groups
+
     def search(self,
                query,
                approxMatches=None,
@@ -743,6 +840,86 @@ class InstitutionMethods:
             raise IbisException(result.error)
         return result.institutions
 
+    def modifiedInsts(self,
+                      minTxId,
+                      maxTxId,
+                      instids=None,
+                      includeCancelled=None,
+                      contactRowChanges=None,
+                      membershipChanges=None,
+                      fetch=None):
+        """
+        Find all institutions modified between the specified pair of
+        transactions.
+
+        The transaction IDs specified should be the IDs from two different
+        requests for the last (most recent) transaction ID, made at different
+        times, that returned different values, indicating that some Lookup
+        data was modified in the period between the two requests. This method
+        then determines which (if any) institutions were affected.
+
+        By default, only a few basic details about each institution are
+        returned, but the optional `fetch` parameter may be used
+        to fetch additional attributes or references.
+
+        .. note::
+          All data returned reflects the latest available data about each
+          institution. It is not possible to query for old data, or more
+          detailed information about the specific changes made.
+
+        ``[ HTTP: GET /api/v1/inst/modified-insts?minTxId=...&maxTxId=... ]``
+
+        **Parameters**
+          `minTxId` : long
+            [required] Include modifications made in transactions
+            after (but not including) this one.
+
+          `maxTxId` : long
+            [required] Include modifications made in transactions
+            up to and including this one.
+
+          `instids` : str
+            [optional] Only include institutions with instids in
+            this list. By default, all modified institutions will be included.
+
+          `includeCancelled` : bool
+            [optional] Include cancelled institutions. By
+            default, cancelled institutions are excluded.
+
+          `contactRowChanges` : bool
+            [optional] Include institutions whose contact
+            rows have changed. By default, changes to institution contact rows are
+            not taken into consideration.
+
+          `membershipChanges` : bool
+            [optional] Include institutions whose members
+            have changed. By default, changes to institutional memberships are not
+            taken into consideration.
+
+          `fetch` : str
+            [optional] A comma-separated list of any additional
+            attributes or references to fetch.
+
+        **Returns**
+          list of :any:`IbisInstitution`
+            The modified institutions (in instid order).
+        """
+        path = "api/v1/inst/modified-insts"
+        path_params = {}
+        query_params = {"minTxId": minTxId,
+                        "maxTxId": maxTxId,
+                        "instids": instids,
+                        "includeCancelled": includeCancelled,
+                        "contactRowChanges": contactRowChanges,
+                        "membershipChanges": membershipChanges,
+                        "fetch": fetch}
+        form_params = {}
+        result = self.conn.invoke_method("GET", path, path_params,
+                                         query_params, form_params)
+        if result.error:
+            raise IbisException(result.error)
+        return result.institutions
+
     def search(self,
                query,
                approxMatches=None,
@@ -1485,6 +1662,87 @@ class PersonMethods:
             raise IbisException(result.error)
         return result.people
 
+    def modifiedPeople(self,
+                       minTxId,
+                       maxTxId,
+                       crsids=None,
+                       includeCancelled=None,
+                       membershipChanges=None,
+                       instNameChanges=None,
+                       fetch=None):
+        """
+        Find all people modified between the specified pair of transactions.
+
+        The transaction IDs specified should be the IDs from two different
+        requests for the last (most recent) transaction ID, made at different
+        times, that returned different values, indicating that some Lookup
+        data was modified in the period between the two requests. This method
+        then determines which (if any) people were affected.
+
+        By default, only a few basic details about each person are returned,
+        but the optional `fetch` parameter may be used to fetch
+        additional attributes or references.
+
+        .. note::
+          All data returned reflects the latest available data about each
+          person. It is not possible to query for old data, or more detailed
+          information about the specific changes made.
+
+        ``[ HTTP: GET /api/v1/person/modified-people?minTxId=...&maxTxId=... ]``
+
+        **Parameters**
+          `minTxId` : long
+            [required] Include modifications made in transactions
+            after (but not including) this one.
+
+          `maxTxId` : long
+            [required] Include modifications made in transactions
+            up to and including this one.
+
+          `crsids` : str
+            [optional] Only include people with identifiers in this
+            list. By default, all modified people will be included.
+
+          `includeCancelled` : bool
+            [optional] Include cancelled people (people
+            who are no longer members of the University). By default, cancelled
+            people are excluded.
+
+          `membershipChanges` : bool
+            [optional] Include people whose group or
+            institutional memberships have changed. By default, only people whose
+            attributes have been directly modified are included.
+
+          `instNameChanges` : bool
+            [optional] Include people who are members of
+            instituions whose names have changed. This will also cause people
+            whose group or institutional memberships have changed to be included.
+            By default, changes to institution names do not propagate to people.
+
+          `fetch` : str
+            [optional] A comma-separated list of any additional
+            attributes or references to fetch.
+
+        **Returns**
+          list of :any:`IbisPerson`
+            The modified people (in identifier order).
+        """
+        path = "api/v1/person/modified-people"
+        path_params = {}
+        query_params = {"minTxId": minTxId,
+                        "maxTxId": maxTxId,
+                        "crsids": crsids,
+                        "includeCancelled": includeCancelled,
+                        "membershipChanges": membershipChanges,
+                        "instNameChanges": instNameChanges,
+                        "fetch": fetch}
+        form_params = {}
+        result = self.conn.invoke_method("GET", path, path_params,
+                                         query_params, form_params)
+        if result.error:
+            raise IbisException(result.error)
+        return result.people
+
     def search(self,
                query,
                approxMatches=None,
diff --git a/src/python/test/unittests.py b/src/python/test/unittests.py
index fef81b6..5c50a67 100644
--- a/src/python/test/unittests.py
+++ b/src/python/test/unittests.py
@@ -22,6 +22,7 @@
 # --------------------------------------------------------------------------
 
 from datetime import date
+import re
 import unittest
 import urllib
 
@@ -43,6 +44,7 @@ run_edit_tests = False
 
 # Globals initialised once and re-used in each test
 conn = None
+m = None
 pm = None
 im = None
 gm = None
@@ -50,7 +52,7 @@ initialised = False
 
 class IbisUnitTests(unittest.TestCase):
     def setUp(self):
-        global conn, pm, im, gm, initialised
+        global conn, m, pm, im, gm, initialised
 
         if not initialised:
             if local:
@@ -58,11 +60,25 @@ class IbisUnitTests(unittest.TestCase):
             else:
                 conn = createTestConnection()
 
+            m = IbisMethods(conn)
             pm = PersonMethods(conn)
             im = InstitutionMethods(conn)
             gm = GroupMethods(conn)
             initialised = True
 
+    # --------------------------------------------------------------------
+    # Ibis tests.
+    # --------------------------------------------------------------------
+
+    def test_get_version(self):
+        version = m.getVersion()
+        self.assertIsNotNone(version)
+        self.assertTrue(re.match("^[0-9]+[.][0-9]+$", version))
+
+    def test_get_last_transaction_id(self):
+        lastTransactionId = m.getLastTransactionId()
+        self.assertTrue(lastTransactionId > 900000)
+
     # --------------------------------------------------------------------
     # Person tests.
     # --------------------------------------------------------------------
@@ -72,6 +88,22 @@ class IbisUnitTests(unittest.TestCase):
         self.assertTrue(len(schemes) > 10)
         self.assertEqual("displayName", schemes[0].schemeid)
 
+    def test_all_people(self):
+        people = pm.allPeople(False, None, 10, None)
+
+        self.assertEquals(10, len(people))
+        self.assertEquals("aa", people[0].identifier.value[0:2])
+
+        people = pm.allPeople(False, "dar17", 10, None)
+        self.assertEquals(10, len(people))
+        self.assertTrue(people[0].identifier.value > "dar17")
+
+        id8 = people[8].identifier.value
+        id9 = people[9].identifier.value
+        people = pm.allPeople(False, id8, 10, None)
+        self.assertEquals(10, len(people))
+        self.assertEquals(id9, people[0].identifier.value)
+
     def test_no_such_person(self):
         person = pm.getPerson("crsid", "dar1734toolong")
         self.assertIsNone(person)
@@ -99,9 +131,9 @@ class IbisUnitTests(unittest.TestCase):
 
     def test_get_person_attributes(self):
         attrs = pm.getAttributes("crsid", "dar17", "email,title")
-        self.assertEqual("title", attrs[0].scheme);
-        self.assertEqual("Database administrator and developer", attrs[0].value);
-        self.assertEqual("email", attrs[1].scheme);
+        self.assertEqual("title", attrs[0].scheme)
+        self.assertEqual("Database administrator and developer", attrs[0].value)
+        self.assertEqual("email", attrs[1].scheme)
 
         attr = pm.getAttribute("crsid", "dar17", attrs[1].attrid)
         self.assertEqual(attrs[1].scheme, attr.scheme)
@@ -152,6 +184,32 @@ class IbisUnitTests(unittest.TestCase):
         count = pm.searchCount("j smith", False, False)
         self.assertTrue(count > 10)
 
+    def test_person_lql_search(self):
+        people = pm.search("person: in inst(uis) and surname=Rasheed", False, False,
+                           None, None, 0, 100, None, "title")
+        self.assertEquals(1, len(people))
+        self.assertEquals("dar17", people[0].identifier.value)
+        self.assertEquals("Database administrator and developer", people[0].attributes[0].value)
+
+        people = pm.search("person: dar54", False, False,
+                           None, None, 0, 100, None, None)
+        self.assertEquals(0, len(people))
+
+        people = pm.search("person: dar54", False, True,
+                           None, None, 0, 100, None, None)
+        self.assertEquals(1, len(people))
+        self.assertEquals("dar54", people[0].identifier.value)
+
+    def test_person_lql_search_count(self):
+        count = pm.searchCount("person: dar17", False, False, None, None)
+        self.assertEquals(1, count)
+
+        count = pm.searchCount("person: dar54", False, False, None, None)
+        self.assertEquals(0, count)
+
+        count = pm.searchCount("person: dar54", False, True, None, None)
+        self.assertEquals(1, count)
+
     def test_is_person_member_of_inst(self):
         self.assertTrue(pm.isMemberOfInst("crsid", "dar17", "UIS"))
         self.assertFalse(pm.isMemberOfInst("crsid", "dar17", "ENG"))
@@ -391,6 +449,30 @@ class IbisUnitTests(unittest.TestCase):
             conn.set_username("anonymous")
             conn.set_password("")
 
+    def test_modified_people(self):
+        # Note: transactions 2..1047 are the same on Lookup and lookup-test
+        people = pm.modifiedPeople(937, 938, None, False, False, False, None)
+        self.assertEquals(1, len(people))
+        self.assertEquals("v4500", people[0].identifier.value)
+
+        people = pm.modifiedPeople(752, 753, None, False, False, False, None)
+        self.assertEquals(0, len(people))
+
+        people = pm.modifiedPeople(752, 753, None, True, False, False, None)
+        self.assertEquals(2, len(people))
+        self.assertEquals("cr10001", people[0].identifier.value)
+        self.assertEquals("gar34", people[1].identifier.value)
+
+        people = pm.modifiedPeople(752, 753, "cr10001", False, False, False, None)
+        self.assertEquals(0, len(people))
+
+        people = pm.modifiedPeople(752, 753, "cr10001", True, False, False, None)
+        self.assertEquals(1, len(people))
+        self.assertEquals("cr10001", people[0].identifier.value)
+
+        people = pm.modifiedPeople(752, 753, "cr10002", True, False, False, None)
+        self.assertEquals(0, len(people))
+
     # --------------------------------------------------------------------
     # Institution tests.
     # --------------------------------------------------------------------
@@ -426,9 +508,9 @@ class IbisUnitTests(unittest.TestCase):
 
     def test_get_inst_attributes(self):
         attrs = im.getAttributes("CS", "acronym,email")
-        self.assertEqual("acronym", attrs[0].scheme);
-        self.assertEqual("UCS", attrs[0].value);
-        self.assertEqual("email", attrs[1].scheme);
+        self.assertEqual("acronym", attrs[0].scheme)
+        self.assertEqual("UCS", attrs[0].value)
+        self.assertEqual("email", attrs[1].scheme)
 
         attr = im.getAttribute("CS", attrs[0].attrid)
         self.assertEqual(attrs[0].scheme, attr.scheme)
@@ -549,6 +631,27 @@ class IbisUnitTests(unittest.TestCase):
         count = im.searchCount("computing")
         self.assertTrue(count > 5)
 
+    def test_inst_lql_search(self):
+        insts = im.search("inst: parent of (uistest)", False, False,
+                          None, 0, 100, None, None)
+        self.assertEquals("UIS", insts[0].instid)
+
+        insts = im.search("inst: address ~ CB3 0JG", False, False,
+                          None, 0, 100, None, "phone_numbers")
+        self.assertEquals(1, len(insts))
+        self.assertEquals("GIRTON", insts[0].instid)
+        self.assertEquals("38999", insts[0].attributes[0].value)
+
+    def test_inst_lql_search_count(self):
+        count = im.searchCount("inst: UIS", False, False, None)
+        self.assertEquals(1, count)
+
+        count = im.searchCount("inst: CS", False, False, None)
+        self.assertEquals(0, count)
+
+        count = im.searchCount("inst: CS", False, True, None)
+        self.assertEquals(1, count)
+
     def test_get_inst_contact_rows(self):
         inst = im.getInst("CS", "contact_rows.jdInstid")
         contactRows = im.getContactRows("CS", "jdInstid")
@@ -734,6 +837,34 @@ class IbisUnitTests(unittest.TestCase):
             conn.set_username("anonymous")
             conn.set_password("")
 
+    def test_modified_insts(self):
+        # Note: transactions 2..1047 are the same on Lookup and lookup-test
+        insts = im.modifiedInsts(491, 492, None, False, False, False, None)
+
+        self.assertEquals(1, len(insts))
+        self.assertEquals("AUT", insts[0].instid)
+
+        insts = im.modifiedInsts(438, 439, None, False, False, False, None)
+        self.assertEquals(0, len(insts))
+
+        insts = im.modifiedInsts(438, 439, None, True, False, False, None)
+        self.assertEquals(1, len(insts))
+        self.assertEquals("SPVSR04", insts[0].instid)
+
+        insts = im.modifiedInsts(764, 765, "IUSCMED", False, False, False, None)
+        self.assertEquals(1, len(insts))
+        self.assertEquals("IUSCMED", insts[0].instid)
+
+        insts = im.modifiedInsts(764, 765, "IUSCMED2", False, False, False, None)
+        self.assertEquals(0, len(insts))
+
+        insts = im.modifiedInsts(45, 46, None, False, False, False, None)
+        self.assertEquals(0, len(insts))
+
+        insts = im.modifiedInsts(45, 46, None, False, True, False, None)
+        self.assertEquals(1, len(insts))
+        self.assertEquals("Clare Hall", insts[0].name)
+
     # --------------------------------------------------------------------
     # Group tests.
     # --------------------------------------------------------------------
@@ -787,7 +918,7 @@ class IbisUnitTests(unittest.TestCase):
             if person.identifier.scheme == "crsid" and\
                person.identifier.value == "dar54":
                 self.assertEquals("Dr D.A. Rasheed", person.registeredName)
-                found = True;
+                found = True
         self.assertTrue(found)
 
     def test_get_group_insts(self):
@@ -829,6 +960,33 @@ class IbisUnitTests(unittest.TestCase):
         count = gm.searchCount("maths editors")
         self.assertEqual(6, count)
 
+    def test_group_lql_search(self):
+        groups = gm.search("group: title='Editors group for \"UIS\"'",
+                           False, False, 0, 100, None, None)
+        self.assertEquals("uis-editors", groups[0].name)
+
+        groups = gm.search("group: uistest-members", False, False,
+                           0, 1, None, "all_members")
+        self.assertEquals(1, len(groups))
+        self.assertEquals("uistest-members", groups[0].name)
+        self.assertTrue(len(groups[0].members) > 10)
+        self.assertEquals("abc123", groups[0].members[0].identifier.value)
+
+        groups = gm.search("group: biotec-editors", False, False,
+                           0, 1, None, None)
+        self.assertEquals(0, len(groups))
+
+        groups = gm.search("group: biotec-editors", False, True,
+                           0, 1, None, None)
+        self.assertEquals(1, len(groups))
+
+    def test_group_lql_search_count(self):
+        count = gm.searchCount("group: biotec-editors", False, False)
+        self.assertEquals(0, count)
+
+        count = gm.searchCount("group: biotec-editors", False, True)
+        self.assertEquals(1, count)
+
     def test_edit_group_members(self):
         if not run_edit_tests:
             return
@@ -882,6 +1040,37 @@ class IbisUnitTests(unittest.TestCase):
             conn.set_username("anonymous")
             conn.set_password("")
 
+    def test_modified_groups(self):
+        # Note: transactions 2..1047 are the same on Lookup and lookup-test
+        groups = gm.modifiedGroups(492, 493, None, False, False, None)
+
+        self.assertEquals(1, len(groups))
+        self.assertEquals("100426", groups[0].groupid)
+        self.assertEquals("maths-intakes-editors", groups[0].name)
+
+        groups = gm.modifiedGroups(487, 488, None, False, False, None)
+        self.assertEquals(0, len(groups))
+
+        groups = gm.modifiedGroups(487, 488, None, True, False, None)
+        self.assertEquals(1, len(groups))
+        self.assertEquals("100855", groups[0].groupid)
+        self.assertEquals("cstest-foofoo", groups[0].name)
+
+        groups = gm.modifiedGroups(743, 744, "100259", False, False, None)
+        self.assertEquals(1, len(groups))
+        self.assertEquals("100259", groups[0].groupid)
+        self.assertEquals("biol-managers", groups[0].name)
+
+        groups = gm.modifiedGroups(743, 744, "biol-managers", False, False, None)
+        self.assertEquals(1, len(groups))
+        self.assertEquals("100259", groups[0].groupid)
+        self.assertEquals("biol-managers", groups[0].name)
+
+        groups = gm.modifiedGroups(743, 744, "100260", False, False, None)
+        self.assertEquals(0, len(groups))
+        groups = gm.modifiedGroups(743, 744, "biol-editors", False, False, None)
+        self.assertEquals(0, len(groups))
+
 if __name__ == '__main__':
     suite = unittest.TestLoader().loadTestsFromTestCase(IbisUnitTests)
     unittest.TextTestRunner(verbosity=2).run(suite)
diff --git a/src/python3/ibisclient/methods.py b/src/python3/ibisclient/methods.py
index 7c37913..556fe0f 100644
--- a/src/python3/ibisclient/methods.py
+++ b/src/python3/ibisclient/methods.py
@@ -36,6 +36,31 @@ class IbisMethods:
     def __init__(self, conn):
         self.conn = conn
 
+    def getLastTransactionId(self):
+        """
+        Get the ID of the last (most recent) transaction.
+
+        A transaction represents an edit made to data in Lookup. Each
+        transaction is assigned a unique, sequential, numeric ID. Thus
+        this last transaction ID will increase each time some data in
+        Lookup is changed.
+
+        ``[ HTTP: GET /api/v1/last-transaction ]``
+
+        **Returns**
+          long
+            The ID of the latest transaction.
+        """
+        path = "api/v1/last-transaction"
+        path_params = {}
+        query_params = {}
+        form_params = {}
+        result = self.conn.invoke_method("GET", path, path_params,
+                                         query_params, form_params)
+        if result.error:
+            raise IbisException(result.error)
+        return int(result.value)
+
     def getVersion(self):
         """
         Get the current API version number.
@@ -208,6 +233,78 @@ class GroupMethods:
             raise IbisException(result.error)
         return result.groups
 
+    def modifiedGroups(self,
+                       minTxId,
+                       maxTxId,
+                       groupids=None,
+                       includeCancelled=None,
+                       membershipChanges=None,
+                       fetch=None):
+        """
+        Find all groups modified between the specified pair of transactions.
+
+        The transaction IDs specified should be the IDs from two different
+        requests for the last (most recent) transaction ID, made at different
+        times, that returned different values, indicating that some Lookup
+        data was modified in the period between the two requests. This method
+        then determines which (if any) groups were affected.
+
+        By default, only a few basic details about each group are returned,
+        but the optional `fetch` parameter may be used to fetch
+        additional attributes or references.
+
+        .. note::
+          All data returned reflects the latest available data about each
+          group. It is not possible to query for old data, or more detailed
+          information about the specific changes made.
+
+        ``[ HTTP: GET /api/v1/group/modified-groups?minTxId=...&maxTxId=... ]``
+
+        **Parameters**
+          `minTxId` : long
+            [required] Include modifications made in transactions
+            after (but not including) this one.
+
+          `maxTxId` : long
+            [required] Include modifications made in transactions
+            up to and including this one.
+
+          `groupids` : str
+            [optional] Only include groups with IDs or names in
+            this list. By default, all modified groups will be included.
+
+          `includeCancelled` : bool
+            [optional] Include cancelled groups. By
+            default, cancelled groups are excluded.
+
+          `membershipChanges` : bool
+            [optional] Include groups whose members have
+            changed. By default, changes to group memberships are not taken into
+            consideration.
+
+          `fetch` : str
+            [optional] A comma-separated list of any additional
+            attributes or references to fetch.
+
+        **Returns**
+          list of :any:`IbisGroup`
+            The modified groups (in groupid order).
+        """
+        path = "api/v1/group/modified-groups"
+        path_params = {}
+        query_params = {"minTxId": minTxId,
+                        "maxTxId": maxTxId,
+                        "groupids": groupids,
+                        "includeCancelled": includeCancelled,
+                        "membershipChanges": membershipChanges,
+                        "fetch": fetch}
+        form_params = {}
+        result = self.conn.invoke_method("GET", path, path_params,
+                                         query_params, form_params)
+        if result.error:
+            raise IbisException(result.error)
+        return result.groups
+
     def search(self,
                query,
                approxMatches=None,
@@ -743,6 +840,86 @@ class InstitutionMethods:
             raise IbisException(result.error)
         return result.institutions
 
+    def modifiedInsts(self,
+                      minTxId,
+                      maxTxId,
+                      instids=None,
+                      includeCancelled=None,
+                      contactRowChanges=None,
+                      membershipChanges=None,
+                      fetch=None):
+        """
+        Find all institutions modified between the specified pair of
+        transactions.
+
+        The transaction IDs specified should be the IDs from two different
+        requests for the last (most recent) transaction ID, made at different
+        times, that returned different values, indicating that some Lookup
+        data was modified in the period between the two requests. This method
+        then determines which (if any) institutions were affected.
+
+        By default, only a few basic details about each institution are
+        returned, but the optional `fetch` parameter may be used
+        to fetch additional attributes or references.
+
+        .. note::
+          All data returned reflects the latest available data about each
+          institution. It is not possible to query for old data, or more
+          detailed information about the specific changes made.
+
+        ``[ HTTP: GET /api/v1/inst/modified-insts?minTxId=...&maxTxId=... ]``
+
+        **Parameters**
+          `minTxId` : long
+            [required] Include modifications made in transactions
+            after (but not including) this one.
+
+          `maxTxId` : long
+            [required] Include modifications made in transactions
+            up to and including this one.
+
+          `instids` : str
+            [optional] Only include institutions with instids in
+            this list. By default, all modified institutions will be included.
+
+          `includeCancelled` : bool
+            [optional] Include cancelled institutions. By
+            default, cancelled institutions are excluded.
+
+          `contactRowChanges` : bool
+            [optional] Include institutions whose contact
+            rows have changed. By default, changes to institution contact rows are
+            not taken into consideration.
+
+          `membershipChanges` : bool
+            [optional] Include institutions whose members
+            have changed. By default, changes to institutional memberships are not
+            taken into consideration.
+
+          `fetch` : str
+            [optional] A comma-separated list of any additional
+            attributes or references to fetch.
+
+        **Returns**
+          list of :any:`IbisInstitution`
+            The modified institutions (in instid order).
+        """
+        path = "api/v1/inst/modified-insts"
+        path_params = {}
+        query_params = {"minTxId": minTxId,
+                        "maxTxId": maxTxId,
+                        "instids": instids,
+                        "includeCancelled": includeCancelled,
+                        "contactRowChanges": contactRowChanges,
+                        "membershipChanges": membershipChanges,
+                        "fetch": fetch}
+        form_params = {}
+        result = self.conn.invoke_method("GET", path, path_params,
+                                         query_params, form_params)
+        if result.error:
+            raise IbisException(result.error)
+        return result.institutions
+
     def search(self,
                query,
                approxMatches=None,
@@ -1485,6 +1662,87 @@ class PersonMethods:
             raise IbisException(result.error)
         return result.people
 
+    def modifiedPeople(self,
+                       minTxId,
+                       maxTxId,
+                       crsids=None,
+                       includeCancelled=None,
+                       membershipChanges=None,
+                       instNameChanges=None,
+                       fetch=None):
+        """
+        Find all people modified between the specified pair of transactions.
+
+        The transaction IDs specified should be the IDs from two different
+        requests for the last (most recent) transaction ID, made at different
+        times, that returned different values, indicating that some Lookup
+        data was modified in the period between the two requests. This method
+        then determines which (if any) people were affected.
+
+        By default, only a few basic details about each person are returned,
+        but the optional `fetch` parameter may be used to fetch
+        additional attributes or references.
+
+        .. note::
+          All data returned reflects the latest available data about each
+          person. It is not possible to query for old data, or more detailed
+          information about the specific changes made.
+
+        ``[ HTTP: GET /api/v1/person/modified-people?minTxId=...&maxTxId=... ]``
+
+        **Parameters**
+          `minTxId` : long
+            [required] Include modifications made in transactions
+            after (but not including) this one.
+
+          `maxTxId` : long
+            [required] Include modifications made in transactions
+            up to and including this one.
+
+          `crsids` : str
+            [optional] Only include people with identifiers in this
+            list. By default, all modified people will be included.
+
+          `includeCancelled` : bool
+            [optional] Include cancelled people (people
+            who are no longer members of the University). By default, cancelled
+            people are excluded.
+
+          `membershipChanges` : bool
+            [optional] Include people whose group or
+            institutional memberships have changed. By default, only people whose
+            attributes have been directly modified are included.
+
+          `instNameChanges` : bool
+            [optional] Include people who are members of
+            instituions whose names have changed. This will also cause people
+            whose group or institutional memberships have changed to be included.
+            By default, changes to institution names do not propagate to people.
+
+          `fetch` : str
+            [optional] A comma-separated list of any additional
+            attributes or references to fetch.
+
+        **Returns**
+          list of :any:`IbisPerson`
+            The modified people (in identifier order).
+        """
+        path = "api/v1/person/modified-people"
+        path_params = {}
+        query_params = {"minTxId": minTxId,
+                        "maxTxId": maxTxId,
+                        "crsids": crsids,
+                        "includeCancelled": includeCancelled,
+                        "membershipChanges": membershipChanges,
+                        "instNameChanges": instNameChanges,
+                        "fetch": fetch}
+        form_params = {}
+        result = self.conn.invoke_method("GET", path, path_params,
+                                         query_params, form_params)
+        if result.error:
+            raise IbisException(result.error)
+        return result.people
+
     def search(self,
                query,
                approxMatches=None,
diff --git a/src/python3/test/unittests.py b/src/python3/test/unittests.py
index f2e19bb..f56c59d 100644
--- a/src/python3/test/unittests.py
+++ b/src/python3/test/unittests.py
@@ -22,6 +22,7 @@
 # --------------------------------------------------------------------------
 
 from datetime import date
+import re
 import unittest
 import urllib.request
 
@@ -43,6 +44,7 @@ run_edit_tests = False
 
 # Globals initialised once and re-used in each test
 conn = None
+m = None
 pm = None
 im = None
 gm = None
@@ -50,7 +52,7 @@ initialised = False
 
 class IbisUnitTests(unittest.TestCase):
     def setUp(self):
-        global conn, pm, im, gm, initialised
+        global conn, m, pm, im, gm, initialised
 
         if not initialised:
             if local:
@@ -58,11 +60,25 @@ class IbisUnitTests(unittest.TestCase):
             else:
                 conn = createTestConnection()
 
+            m = IbisMethods(conn)
             pm = PersonMethods(conn)
             im = InstitutionMethods(conn)
             gm = GroupMethods(conn)
             initialised = True
 
+    # --------------------------------------------------------------------
+    # Ibis tests.
+    # --------------------------------------------------------------------
+
+    def test_get_version(self):
+        version = m.getVersion()
+        self.assertIsNotNone(version)
+        self.assertTrue(re.match("^[0-9]+[.][0-9]+$", version))
+
+    def test_get_last_transaction_id(self):
+        lastTransactionId = m.getLastTransactionId()
+        self.assertTrue(lastTransactionId > 900000)
+
     # --------------------------------------------------------------------
     # Person tests.
     # --------------------------------------------------------------------
@@ -72,6 +88,22 @@ class IbisUnitTests(unittest.TestCase):
         self.assertTrue(len(schemes) > 10)
         self.assertEqual("displayName", schemes[0].schemeid)
 
+    def test_all_people(self):
+        people = pm.allPeople(False, None, 10, None)
+
+        self.assertEquals(10, len(people))
+        self.assertEquals("aa", people[0].identifier.value[0:2])
+
+        people = pm.allPeople(False, "dar17", 10, None)
+        self.assertEquals(10, len(people))
+        self.assertTrue(people[0].identifier.value > "dar17")
+
+        id8 = people[8].identifier.value
+        id9 = people[9].identifier.value
+        people = pm.allPeople(False, id8, 10, None)
+        self.assertEquals(10, len(people))
+        self.assertEquals(id9, people[0].identifier.value)
+
     def test_no_such_person(self):
         person = pm.getPerson("crsid", "dar1734toolong")
         self.assertIsNone(person)
@@ -99,9 +131,9 @@ class IbisUnitTests(unittest.TestCase):
 
     def test_get_person_attributes(self):
         attrs = pm.getAttributes("crsid", "dar17", "email,title")
-        self.assertEqual("title", attrs[0].scheme);
-        self.assertEqual("Database administrator and developer", attrs[0].value);
-        self.assertEqual("email", attrs[1].scheme);
+        self.assertEqual("title", attrs[0].scheme)
+        self.assertEqual("Database administrator and developer", attrs[0].value)
+        self.assertEqual("email", attrs[1].scheme)
 
         attr = pm.getAttribute("crsid", "dar17", attrs[1].attrid)
         self.assertEqual(attrs[1].scheme, attr.scheme)
@@ -152,6 +184,32 @@ class IbisUnitTests(unittest.TestCase):
         count = pm.searchCount("j smith", False, False)
         self.assertTrue(count > 10)
 
+    def test_person_lql_search(self):
+        people = pm.search("person: in inst(uis) and surname=Rasheed", False, False,
+                           None, None, 0, 100, None, "title")
+        self.assertEquals(1, len(people))
+        self.assertEquals("dar17", people[0].identifier.value)
+        self.assertEquals("Database administrator and developer", people[0].attributes[0].value)
+
+        people = pm.search("person: dar54", False, False,
+                           None, None, 0, 100, None, None)
+        self.assertEquals(0, len(people))
+
+        people = pm.search("person: dar54", False, True,
+                           None, None, 0, 100, None, None)
+        self.assertEquals(1, len(people))
+        self.assertEquals("dar54", people[0].identifier.value)
+
+    def test_person_lql_search_count(self):
+        count = pm.searchCount("person: dar17", False, False, None, None)
+        self.assertEquals(1, count)
+
+        count = pm.searchCount("person: dar54", False, False, None, None)
+        self.assertEquals(0, count)
+
+        count = pm.searchCount("person: dar54", False, True, None, None)
+        self.assertEquals(1, count)
+
     def test_is_person_member_of_inst(self):
         self.assertTrue(pm.isMemberOfInst("crsid", "dar17", "UIS"))
         self.assertFalse(pm.isMemberOfInst("crsid", "dar17", "ENG"))
@@ -391,6 +449,30 @@ class IbisUnitTests(unittest.TestCase):
             conn.set_username("anonymous")
             conn.set_password("")
 
+    def test_modified_people(self):
+        # Note: transactions 2..1047 are the same on Lookup and lookup-test
+        people = pm.modifiedPeople(937, 938, None, False, False, False, None)
+        self.assertEquals(1, len(people))
+        self.assertEquals("v4500", people[0].identifier.value)
+
+        people = pm.modifiedPeople(752, 753, None, False, False, False, None)
+        self.assertEquals(0, len(people))
+
+        people = pm.modifiedPeople(752, 753, None, True, False, False, None)
+        self.assertEquals(2, len(people))
+        self.assertEquals("cr10001", people[0].identifier.value)
+        self.assertEquals("gar34", people[1].identifier.value)
+
+        people = pm.modifiedPeople(752, 753, "cr10001", False, False, False, None)
+        self.assertEquals(0, len(people))
+
+        people = pm.modifiedPeople(752, 753, "cr10001", True, False, False, None)
+        self.assertEquals(1, len(people))
+        self.assertEquals("cr10001", people[0].identifier.value)
+
+        people = pm.modifiedPeople(752, 753, "cr10002", True, False, False, None)
+        self.assertEquals(0, len(people))
+
     # --------------------------------------------------------------------
     # Institution tests.
     # --------------------------------------------------------------------
@@ -426,9 +508,9 @@ class IbisUnitTests(unittest.TestCase):
 
     def test_get_inst_attributes(self):
         attrs = im.getAttributes("CS", "acronym,email")
-        self.assertEqual("acronym", attrs[0].scheme);
-        self.assertEqual("UCS", attrs[0].value);
-        self.assertEqual("email", attrs[1].scheme);
+        self.assertEqual("acronym", attrs[0].scheme)
+        self.assertEqual("UCS", attrs[0].value)
+        self.assertEqual("email", attrs[1].scheme)
 
         attr = im.getAttribute("CS", attrs[0].attrid)
         self.assertEqual(attrs[0].scheme, attr.scheme)
@@ -549,6 +631,27 @@ class IbisUnitTests(unittest.TestCase):
         count = im.searchCount("computing")
         self.assertTrue(count > 5)
 
+    def test_inst_lql_search(self):
+        insts = im.search("inst: parent of (uistest)", False, False,
+                          None, 0, 100, None, None)
+        self.assertEquals("UIS", insts[0].instid)
+
+        insts = im.search("inst: address ~ CB3 0JG", False, False,
+                          None, 0, 100, None, "phone_numbers")
+        self.assertEquals(1, len(insts))
+        self.assertEquals("GIRTON", insts[0].instid)
+        self.assertEquals("38999", insts[0].attributes[0].value)
+
+    def test_inst_lql_search_count(self):
+        count = im.searchCount("inst: UIS", False, False, None)
+        self.assertEquals(1, count)
+
+        count = im.searchCount("inst: CS", False, False, None)
+        self.assertEquals(0, count)
+
+        count = im.searchCount("inst: CS", False, True, None)
+        self.assertEquals(1, count)
+
     def test_get_inst_contact_rows(self):
         inst = im.getInst("CS", "contact_rows.jdInstid")
         contactRows = im.getContactRows("CS", "jdInstid")
@@ -734,6 +837,34 @@ class IbisUnitTests(unittest.TestCase):
             conn.set_username("anonymous")
             conn.set_password("")
 
+    def test_modified_insts(self):
+        # Note: transactions 2..1047 are the same on Lookup and lookup-test
+        insts = im.modifiedInsts(491, 492, None, False, False, False, None)
+
+        self.assertEquals(1, len(insts))
+        self.assertEquals("AUT", insts[0].instid)
+
+        insts = im.modifiedInsts(438, 439, None, False, False, False, None)
+        self.assertEquals(0, len(insts))
+
+        insts = im.modifiedInsts(438, 439, None, True, False, False, None)
+        self.assertEquals(1, len(insts))
+        self.assertEquals("SPVSR04", insts[0].instid)
+
+        insts = im.modifiedInsts(764, 765, "IUSCMED", False, False, False, None)
+        self.assertEquals(1, len(insts))
+        self.assertEquals("IUSCMED", insts[0].instid)
+
+        insts = im.modifiedInsts(764, 765, "IUSCMED2", False, False, False, None)
+        self.assertEquals(0, len(insts))
+
+        insts = im.modifiedInsts(45, 46, None, False, False, False, None)
+        self.assertEquals(0, len(insts))
+
+        insts = im.modifiedInsts(45, 46, None, False, True, False, None)
+        self.assertEquals(1, len(insts))
+        self.assertEquals("Clare Hall", insts[0].name)
+
     # --------------------------------------------------------------------
     # Group tests.
     # --------------------------------------------------------------------
@@ -787,7 +918,7 @@ class IbisUnitTests(unittest.TestCase):
             if person.identifier.scheme == "crsid" and\
                person.identifier.value == "dar54":
                 self.assertEquals("Dr D.A. Rasheed", person.registeredName)
-                found = True;
+                found = True
         self.assertTrue(found)
 
     def test_get_group_insts(self):
@@ -829,6 +960,33 @@ class IbisUnitTests(unittest.TestCase):
         count = gm.searchCount("maths editors")
         self.assertEqual(6, count)
 
+    def test_group_lql_search(self):
+        groups = gm.search("group: title='Editors group for \"UIS\"'",
+                           False, False, 0, 100, None, None)
+        self.assertEquals("uis-editors", groups[0].name)
+
+        groups = gm.search("group: uistest-members", False, False,
+                           0, 1, None, "all_members")
+        self.assertEquals(1, len(groups))
+        self.assertEquals("uistest-members", groups[0].name)
+        self.assertTrue(len(groups[0].members) > 10)
+        self.assertEquals("abc123", groups[0].members[0].identifier.value)
+
+        groups = gm.search("group: biotec-editors", False, False,
+                           0, 1, None, None)
+        self.assertEquals(0, len(groups))
+
+        groups = gm.search("group: biotec-editors", False, True,
+                           0, 1, None, None)
+        self.assertEquals(1, len(groups))
+
+    def test_group_lql_search_count(self):
+        count = gm.searchCount("group: biotec-editors", False, False)
+        self.assertEquals(0, count)
+
+        count = gm.searchCount("group: biotec-editors", False, True)
+        self.assertEquals(1, count)
+
     def test_edit_group_members(self):
         if not run_edit_tests:
             return
@@ -882,6 +1040,37 @@ class IbisUnitTests(unittest.TestCase):
             conn.set_username("anonymous")
             conn.set_password("")
 
+    def test_modified_groups(self):
+        # Note: transactions 2..1047 are the same on Lookup and lookup-test
+        groups = gm.modifiedGroups(492, 493, None, False, False, None)
+
+        self.assertEquals(1, len(groups))
+        self.assertEquals("100426", groups[0].groupid)
+        self.assertEquals("maths-intakes-editors", groups[0].name)
+
+        groups = gm.modifiedGroups(487, 488, None, False, False, None)
+        self.assertEquals(0, len(groups))
+
+        groups = gm.modifiedGroups(487, 488, None, True, False, None)
+        self.assertEquals(1, len(groups))
+        self.assertEquals("100855", groups[0].groupid)
+        self.assertEquals("cstest-foofoo", groups[0].name)
+
+        groups = gm.modifiedGroups(743, 744, "100259", False, False, None)
+        self.assertEquals(1, len(groups))
+        self.assertEquals("100259", groups[0].groupid)
+        self.assertEquals("biol-managers", groups[0].name)
+
+        groups = gm.modifiedGroups(743, 744, "biol-managers", False, False, None)
+        self.assertEquals(1, len(groups))
+        self.assertEquals("100259", groups[0].groupid)
+        self.assertEquals("biol-managers", groups[0].name)
+
+        groups = gm.modifiedGroups(743, 744, "100260", False, False, None)
+        self.assertEquals(0, len(groups))
+        groups = gm.modifiedGroups(743, 744, "biol-editors", False, False, None)
+        self.assertEquals(0, len(groups))
+
 if __name__ == '__main__':
     suite = unittest.TestLoader().loadTestsFromTestCase(IbisUnitTests)
     unittest.TextTestRunner(verbosity=2).run(suite)
-- 
GitLab