irongit

Enhanced time synchronization for drf views.

passes tests for static time

huncholanehuncholaneauthored
parent 762fdfbcommit e6d640d72158ffcbf4d29fe175ff06ba0df06be8Browse files

8 files changed, +150 -51

+2-2example_app/views.py
@@ -18,7 +18,7 @@ class View(APIView):
1818 )
1919
2020 def put(self, request):
21- instance = Post.objects.filter(request.data.get("id", None)).first()
21+ instance = Post.objects.all().first()
2222 if instance:
23- TimeCheck(request, instance).check_get()
23+ TimeCheck(request, instance).check_update()
2424 return Response({"port": request.META.get("SERVER_PORT")})
+26-1example_project/settings.py
@@ -11,8 +11,33 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
1111 """
1212
1313 from pathlib import Path
14-from typing import TYPE_CHECKING
1514
15+LOGGING = {
16+ "version": 1,
17+ "disable_existing_loggers": False,
18+ "handlers": {
19+ "console": {
20+ "class": "logging.StreamHandler",
21+ },
22+ },
23+ "root": {
24+ "handlers": ["console"],
25+ "level": "DEBUG",
26+ },
27+ "loggers": {
28+ "django": {
29+ "handlers": ["console"],
30+ "level": "ERROR",
31+ "propagate": False,
32+ },
33+ # optionally:
34+ "timecheck": {
35+ "handlers": ["console"],
36+ "level": "DEBUG",
37+ "propagate": False,
38+ },
39+ },
40+}
1641
1742 # TimeCheck Conf
1843 TIMECHECK_CONF = {"noupdate_code": 420}
+7-12timecheck/exceptions.py
@@ -7,41 +7,36 @@ from timecheck.settings import conf
77 class InvalidServerDatetimeField(exceptions.APIException):
88 status_code = 500
99
10- def __init__(self, field_name: str, val: models.Model | object, code: str | int):
10+ def __init__(self, field_name: str, val: models.Model | object):
1111 if isinstance(val, models.Model):
1212 fields = {f.name: getattr(val, f.name) for f in val._meta.fields}
1313 val_info = f"<Model {val.__class__.__name__}: {fields}>"
1414 else:
1515 val_info = repr(val)
16-
1716 super().__init__(
1817 f"InvalidServerDatetimeField: {field_name} is not a valid datetime. {val_info} is a {type(val)}",
19- str(code),
2018 )
2119
2220
2321 class InvalidClientDatetimeField(exceptions.APIException):
2422 status_code = 400
2523
26- def __init__(self, field_name: str, val: str, fmt: str, code: str | int):
27- code = str(code)
24+ def __init__(self, field_name: str, val: str):
2825 super().__init__(
29- f"InvalidClientDatetimeField: {field_name} header {val} val is unable to be parsed with {fmt}",
30- code,
26+ f"InvalidClientDatetimeField: {field_name} header {val}",
3127 )
3228
3329
3430 class NoUpdate(exceptions.APIException):
3531 status_code = conf["noupdate_code"]
3632
37- def __init__(self, request_method: str | None, code: str | int) -> None:
38- code = str(code)
33+ def __init__(self, request_method: str | None, code: int) -> None:
34+ self.status_code = code
3935 if request_method in ["POST", "PUT"]:
4036 super().__init__(
4137 "NoUpdate: Client has submitted older data than the server. Skipping update",
42- code,
4338 )
4439 elif request_method == "GET":
45- super().__init__("NoUpdate: Client is already up to date", code)
40+ super().__init__("NoUpdate: Client is already up to date")
4641 else:
47- super().__init__("NoUpdate", code)
42+ super().__init__("NoUpdate")
+2-1timecheck/settings.py
@@ -19,12 +19,13 @@ def getval(t: type, k: str, d: Any):
1919 conf = TimeCheckConf(
2020 {
2121 "body_timestamp_field": getval(str, "body_timestamp_field", "lastUpdated"),
22- "datetime_format": getval(str, "datetime_format", "%Y-%m-%dT%H:%M:S%z"),
22+ "datetime_format": getval(str, "datetime_format", "%Y-%m-%dT%H:%M:%S%z"),
2323 "header_timestamp_field": getval(str, "header_timestamp_field", "lastUpdated"),
2424 "instance_timestamp_field": getval(
2525 str, "instance_timestamp_field", "lastUpdated"
2626 ),
2727 "missing_action": getval(str, "missing_action", "noupdate"),
2828 "noupdate_code": getval(int, "noupdate_code", 418),
29+ "replace_with_z": getval(bool, "replace_with_z", True),
2930 }
3031 )
+61-0timecheck/tests.py
@@ -0,0 +1,61 @@
1+import datetime as dt
2+from django.utils.timezone import make_aware
3+from rest_framework.test import APIClient
4+from rest_framework import status
5+from django.test import TestCase
6+from example_app.models import Post
7+from timecheck.settings import conf
8+from timecheck.utils import fmt_dt, parse_dt
9+
10+
11+class TimeCheckTests(TestCase):
12+ def setUp(self):
13+ self.client = APIClient()
14+ self.now = parse_dt("2024-01-01T12:00:00Z")
15+ self.later = parse_dt("2024-01-02T12:00:00Z")
16+ self.earlier = parse_dt("2024-01-01T11:00:00Z")
17+
18+ def test_get_client_is_newer(self):
19+ client_time = fmt_dt(self.later)
20+ response = self.client.get("/", HTTP_LASTUPDATED=client_time)
21+ self.assertEqual(response.status_code, conf["noupdate_code"])
22+
23+ def test_get_client_is_older(self):
24+ client_time = fmt_dt(self.earlier)
25+ response = self.client.get("/", HTTP_LASTUPDATED=client_time)
26+ self.assertEqual(response.status_code, 200)
27+
28+ def test_get_client_is_equal(self):
29+ client_time = fmt_dt(self.now)
30+ response = self.client.get("/", HTTP_LASTUPDATED=client_time)
31+ self.assertEqual(response.status_code, conf["noupdate_code"])
32+
33+ def test_put_client_is_newer(self):
34+ client_time = fmt_dt(self.later)
35+ response = self.client.put(
36+ "/",
37+ {
38+ "id": 1,
39+ "lastUpdated": client_time,
40+ },
41+ format="json",
42+ )
43+ self.assertEqual(response.status_code, 200)
44+
45+ def test_put_client_is_older(self):
46+ client_time = fmt_dt(self.earlier)
47+ response = self.client.put(
48+ "/",
49+ {"lastUpdated": client_time},
50+ format="json",
51+ )
52+ self.assertEqual(response.status_code, conf["noupdate_code"])
53+
54+ def test_put_client_is_equal(self):
55+ client_time = fmt_dt(self.now)
56+ response = self.client.put(
57+ "/",
58+ {"lastUpdated": client_time},
59+ format="json",
60+ )
61+ self.assertEqual(response.status_code, conf["noupdate_code"])
+32-35timecheck/timecheck.py
@@ -9,6 +9,12 @@ from timecheck.exceptions import (
99 )
1010 from timecheck.types import MissingAction
1111 from timecheck.settings import conf
12+import logging
13+
14+from timecheck.utils import parse_dt
15+
16+
17+logger = logging.getLogger(__name__)
1218
1319
1420 class TimeCheckPrivate:
@@ -20,24 +26,22 @@ class TimeCheckPrivate:
2026
2127 def __init__(
2228 self,
23- parent: Request,
29+ request: Request,
2430 instance: models.Model | None = None,
2531 server_timestamp: dt.datetime | None = None,
2632 client_timestamp: dt.datetime | None = None,
27- header_field: str | None = None,
28- body_field: str | None = None,
29- instance_field: str | None = None,
30- missing_action: MissingAction | None = None,
31- noupdate_code: str | None = None,
32- datetime_format: str | None = None,
33+ header_field=conf["header_timestamp_field"],
34+ body_field=conf["body_timestamp_field"],
35+ instance_field=conf["instance_timestamp_field"],
36+ missing_action: MissingAction = conf["missing_action"],
37+ noupdate_code=conf["noupdate_code"],
3338 ):
34- self.parent = parent
39+ self.request = request
3540 self._header_field = header_field or conf["header_timestamp_field"]
3641 self._body_field = body_field or conf["body_timestamp_field"]
3742 self._instance_field = instance_field or conf["instance_timestamp_field"]
3843 self._noupdate_code = noupdate_code or conf["noupdate_code"]
3944 self._missing_action = missing_action or conf["missing_action"]
40- self._datetime_format = datetime_format or conf["datetime_format"]
4145 self.client_timestamp = client_timestamp
4246
4347 if server_timestamp:
@@ -45,52 +49,45 @@ class TimeCheckPrivate:
4549 elif self._instance_field and hasattr(instance, self._instance_field):
4650 val = getattr(instance, self._instance_field)
4751 if isinstance(val, dt.datetime):
48- self.server_time = val
52+ self.server_timestamp = val
4953 else:
5054 raise InvalidServerDatetimeField(
5155 self._instance_field or "No instance field",
5256 instance,
53- self._noupdate_code,
5457 )
5558
5659 if not self.client_timestamp:
57- header_str = self.parent.headers.get(self._header_field, None)
58- body_str = self.parent.data.get(self._body_field, None)
60+ header_str = self.request.headers.get(self._header_field, None)
61+ body_str = self.request.data.get(self._body_field, None)
5962 if header_str:
6063 try:
61- self.client_timestamp = dt.datetime.strptime(
62- header_str, self._datetime_format
63- )
64+ self.client_timestamp = parse_dt(header_str)
6465 except Exception:
65- raise InvalidClientDatetimeField(
66- self._header_field, header_str, self._datetime_format, 400
67- )
66+ raise InvalidClientDatetimeField(self._header_field, header_str)
6867 elif body_str:
6968 try:
70- self.client_timestamp = dt.datetime.strptime(
71- body_str, self._datetime_format
72- )
69+ self.client_timestamp = parse_dt(body_str)
7370 except Exception:
74- raise InvalidClientDatetimeField(
75- self._body_field, body_str, self._datetime_format, 400
76- )
71+ raise InvalidClientDatetimeField(self._body_field, body_str)
7772
7873 def check_get(self):
7974 """Raises a drf exception `NoUpdate` which provides details that there is no need to give data back to the user."""
75+ logger.debug(
76+ f"Checking get: client={self.client_timestamp}, server={self.server_timestamp}"
77+ )
8078 if not self.client_timestamp:
8179 if self._missing_action == "noupdate":
82- raise NoUpdate(self.parent.method, self._noupdate_code)
80+ raise NoUpdate(self.request.method, self._noupdate_code)
8381 elif self.client_timestamp >= self.server_timestamp:
84- raise NoUpdate(self.parent.method, self._noupdate_code)
82+ raise NoUpdate(self.request.method, self._noupdate_code)
8583
86- def check_update(
87- self, code: str | int | None = None, missing_action: MissingAction | None = None
88- ):
84+ def check_update(self):
8985 """Raises a drf exception `NoUpdate` which provides details that there is no need to update the server."""
90- code = code or self._noupdate_code
91- missing_action = missing_action or self._missing_action
86+ logger.debug(
87+ f"Checking update: client={self.client_timestamp}, server={self.server_timestamp}"
88+ )
9289 if not self.client_timestamp:
9390 if self._missing_action == "noupdate":
94- raise NoUpdate(self.parent.method, code)
95- elif self.client_timestamp < self.server_timestamp:
96- raise NoUpdate(self.parent.method, code)
91+ raise NoUpdate(self.request.method, self._noupdate_code)
92+ elif self.client_timestamp <= self.server_timestamp:
93+ raise NoUpdate(self.request.method, self._noupdate_code)
+1-0timecheck/types.py
@@ -17,3 +17,4 @@ class TimeCheckConf(TypedDict):
1717 missing_action: MissingAction
1818 """What do do when the client does not provide a timestamp"""
1919 datetime_format: str
20+ replace_with_z: bool
+19-0timecheck/utils.py
@@ -0,0 +1,19 @@
1+import datetime as dt
2+from rest_framework import serializers
3+
4+from timecheck.settings import conf
5+
6+
7+def parse_dt(s: str):
8+ return serializers.DateTimeField().to_internal_value(s)
9+
10+
11+def fmt_dt(
12+ time: dt.datetime,
13+ fmt: str = conf["datetime_format"],
14+ replace_with_z=conf["replace_with_z"],
15+):
16+ s = dt.datetime.strftime(time, fmt)
17+ if replace_with_z:
18+ return s.replace("+0000", "Z")
19+ return s