Repository: almarklein/timetagger (language: python)

Issue:
Web API
The main purpose of this PR is to provide a public API so that custom clients can communicate with the TimeTagger server. However, this work has resulted in some additional improvements:

* The server API logic is now fully async (this included a detour to add better async support for ItemDB (https://github.com/almarklein/itemdb/pull/2)).
* The authentication workflow has changed. After a bootstrap, the auth occurs via a webtoken provided by the TimeTagger server itself. Advantages:
  * More control, e.g. we let it expire after 14 days but is refreshable, striking a good balance between practicality and security (no re-login needed if you use it at least once per 2 weeks).
  * The webtokens can be revoked, i.e. force other devices to log out.
  * Easier to implement a custom auth workflow to bootstrap the authentication.
* The app-menu is better structured and also looks better.
* The default light/dark mode is light (instead of following the OS), because the dark mode is not pretty yet.
* Do our own simplified scss parsing (no more tons of warnings from pyscss).
* A working account page, plus some tweaks to the from page.

Todo:
* [x] Refactor auth workflow to use our own webtokens for the web client too.
* [x] Allow users to obtain an API token.
* [x] Implement a public API, which uses either webtokens or apitokens to authenticate.
* [x] Make the API code fully async (no more `asyncthis`)
* [x] Document this API. -> done, but will commit in a new doc-pr.
* [x] Tweak the menu (e.g. move keyboard shortcut and install instrucitons to the about dialog).
* [x] Remove refresh iframe
* [x] Fix tests, and add more tests.
* [x] The auth bootstrap could also return JSON for consistency.
* [x] Check todos.
* [x] Self-review.



Discussion / hints:

Fix patch:
diff --git a/timetagger/server/_apiserver.py b/timetagger/server/_apiserver.py
index ebcfeab4..484db156 100644
--- a/timetagger/server/_apiserver.py
+++ b/timetagger/server/_apiserver.py
@@ -358,7 +358,8 @@ async def get_records(request, auth_info, db):
         return 400, {}, "bad request: /records timerange needs 2 numbers (timestamps)"
 
     # Collect records
-    query = f"t2 >= {timerange[0]} AND t1 <= {timerange[1]}"
+    tr1, tr2 = int(timerange[0]), int(timerange[1])
+    query = f"(t2 >= {tr1} AND t1 <= {tr2}) OR (t1 == t2 AND t1 <= {tr2})"
     records = await db.select("records", query)
 
     # Return result


Test patch:
diff --git a/tests/test_server_apiserver.py b/tests/test_server_apiserver.py
index c6bf38f7..d91b95be 100644
--- a/tests/test_server_apiserver.py
+++ b/tests/test_server_apiserver.py
@@ -454,7 +454,7 @@ def test_records_get():
         records = [
             dict(key="r11", mt=110, t1=100, t2=150, ds="#p1"),
             dict(key="r12", mt=110, t1=310, t2=350, ds="#p1"),
-            dict(key="r13", mt=110, t1=700, t2=700, ds="#p1"),
+            dict(key="r13", mt=110, t1=700, t2=701, ds="#p1"),
         ]
         r = p.put(
             "http://localhost/api/v2/records",
@@ -490,7 +490,7 @@ def test_records_get():
             assert set(d.keys()) == {"records"}
             assert set(r["key"] for r in d["records"]) == {"r12"}
 
-        # Get section containing only third (running record)
+        # Get section containing only third (short record)
         for tr in ["600-800", "699-701"]:
             r = p.get(
                 f"http://localhost/api/v2/records?timerange={tr}", headers=HEADERS
@@ -500,6 +500,59 @@ def test_records_get():
             assert set(d.keys()) == {"records"}
             assert set(r["key"] for r in d["records"]) == {"r13"}
 
+        # Using a zero range works if that timestamp is inside a record
+        r = p.get(f"http://localhost/api/v2/records?timerange=120-120", headers=HEADERS)
+        assert r.status == 200
+        assert set(r["key"] for r in dejsonize(r)["records"]) == {"r11"}
+
+        # Using a reversed range works if that range is fully inside a record
+        r = p.get(f"http://localhost/api/v2/records?timerange=140-110", headers=HEADERS)
+        assert r.status == 200
+        assert set(r["key"] for r in dejsonize(r)["records"]) == {"r11"}
+        # And only fully
+        r = p.get(f"http://localhost/api/v2/records?timerange=151-110", headers=HEADERS)
+        assert r.status == 200
+        assert set(r["key"] for r in dejsonize(r)["records"]) == set()
+
+        # Add some running records
+        now = int(time.time())
+        records = [
+            dict(key="r21", mt=110, t1=now - 2000, t2=now - 2000, ds="#p1"),
+            dict(key="r22", mt=110, t1=now - 3000, t2=now - 3000, ds="#p1"),
+            dict(key="r23", mt=110, t1=now - 4000, t2=now - 4000, ds="#p1"),
+        ]
+        r = p.put(
+            "http://localhost/api/v2/records",
+            json.dumps(records).encode(),
+            headers=HEADERS,
+        )
+        assert r.status == 200
+
+        # If we sample *somewhere* in a running records range, we'd get it
+        for tr in [(-5000, 10), (-2500, 10), (-100, -90), (100, 110), (0, 0)]:
+            trs = f"{now + tr[0]}-{now + tr[1]}"
+            r = p.get(
+                f"http://localhost/api/v2/records?timerange={trs}", headers=HEADERS
+            )
+            assert r.status == 200
+            d = dejsonize(r)
+            keys = [r["key"] for r in d["records"]]
+            assert set(keys) == {"r21", "r22", "r23"}
+
+        # Query before when the 2nd and 3d have started -> should only get 1st
+        trs = f"{now - 3500}-{now - 3500}"
+        r = p.get(f"http://localhost/api/v2/records?timerange={trs}", headers=HEADERS)
+        d = dejsonize(r)
+        keys = [r["key"] for r in d["records"]]
+        assert set(keys) == {"r23"}
+
+        # Query before when the 3d has started -> should only get 1st and 2nd
+        trs = f"{now - 3500}-{now - 2999}"
+        r = p.get(f"http://localhost/api/v2/records?timerange={trs}", headers=HEADERS)
+        d = dejsonize(r)
+        keys = [r["key"] for r in d["records"]]
+        assert set(keys) == {"r22", "r23"}
+
         # Fails
         r = p.get(f"http://localhost/api/v2/records", headers=HEADERS)
         assert r.status == 400  # no timerange