Enhanced time synchronization for drf views.
passes tests for static time
8 files changed, +150 -51
+2-2example_app/views.py
| @@ -18,7 +18,7 @@ class View(APIView): | ||
| 18 | 18 | ) |
| 19 | 19 | |
| 20 | 20 | def put(self, request): |
| 21 | - instance = Post.objects.filter(request.data.get("id", None)).first() | |
| 21 | + instance = Post.objects.all().first() | |
| 22 | 22 | if instance: |
| 23 | - TimeCheck(request, instance).check_get() | |
| 23 | + TimeCheck(request, instance).check_update() | |
| 24 | 24 | 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/ | ||
| 11 | 11 | """ |
| 12 | 12 | |
| 13 | 13 | from pathlib import Path |
| 14 | -from typing import TYPE_CHECKING | |
| 15 | 14 | |
| 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 | +} | |
| 16 | 41 | |
| 17 | 42 | # TimeCheck Conf |
| 18 | 43 | TIMECHECK_CONF = {"noupdate_code": 420} |
+7-12timecheck/exceptions.py
| @@ -7,41 +7,36 @@ from timecheck.settings import conf | ||
| 7 | 7 | class InvalidServerDatetimeField(exceptions.APIException): |
| 8 | 8 | status_code = 500 |
| 9 | 9 | |
| 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): | |
| 11 | 11 | if isinstance(val, models.Model): |
| 12 | 12 | fields = {f.name: getattr(val, f.name) for f in val._meta.fields} |
| 13 | 13 | val_info = f"<Model {val.__class__.__name__}: {fields}>" |
| 14 | 14 | else: |
| 15 | 15 | val_info = repr(val) |
| 16 | - | |
| 17 | 16 | super().__init__( |
| 18 | 17 | f"InvalidServerDatetimeField: {field_name} is not a valid datetime. {val_info} is a {type(val)}", |
| 19 | - str(code), | |
| 20 | 18 | ) |
| 21 | 19 | |
| 22 | 20 | |
| 23 | 21 | class InvalidClientDatetimeField(exceptions.APIException): |
| 24 | 22 | status_code = 400 |
| 25 | 23 | |
| 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): | |
| 28 | 25 | 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}", | |
| 31 | 27 | ) |
| 32 | 28 | |
| 33 | 29 | |
| 34 | 30 | class NoUpdate(exceptions.APIException): |
| 35 | 31 | status_code = conf["noupdate_code"] |
| 36 | 32 | |
| 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 | |
| 39 | 35 | if request_method in ["POST", "PUT"]: |
| 40 | 36 | super().__init__( |
| 41 | 37 | "NoUpdate: Client has submitted older data than the server. Skipping update", |
| 42 | - code, | |
| 43 | 38 | ) |
| 44 | 39 | 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") | |
| 46 | 41 | 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): | ||
| 19 | 19 | conf = TimeCheckConf( |
| 20 | 20 | { |
| 21 | 21 | "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"), | |
| 23 | 23 | "header_timestamp_field": getval(str, "header_timestamp_field", "lastUpdated"), |
| 24 | 24 | "instance_timestamp_field": getval( |
| 25 | 25 | str, "instance_timestamp_field", "lastUpdated" |
| 26 | 26 | ), |
| 27 | 27 | "missing_action": getval(str, "missing_action", "noupdate"), |
| 28 | 28 | "noupdate_code": getval(int, "noupdate_code", 418), |
| 29 | + "replace_with_z": getval(bool, "replace_with_z", True), | |
| 29 | 30 | } |
| 30 | 31 | ) |
+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 ( | ||
| 9 | 9 | ) |
| 10 | 10 | from timecheck.types import MissingAction |
| 11 | 11 | from timecheck.settings import conf |
| 12 | +import logging | |
| 13 | + | |
| 14 | +from timecheck.utils import parse_dt | |
| 15 | + | |
| 16 | + | |
| 17 | +logger = logging.getLogger(__name__) | |
| 12 | 18 | |
| 13 | 19 | |
| 14 | 20 | class TimeCheckPrivate: |
| @@ -20,24 +26,22 @@ class TimeCheckPrivate: | ||
| 20 | 26 | |
| 21 | 27 | def __init__( |
| 22 | 28 | self, |
| 23 | - parent: Request, | |
| 29 | + request: Request, | |
| 24 | 30 | instance: models.Model | None = None, |
| 25 | 31 | server_timestamp: dt.datetime | None = None, |
| 26 | 32 | 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"], | |
| 33 | 38 | ): |
| 34 | - self.parent = parent | |
| 39 | + self.request = request | |
| 35 | 40 | self._header_field = header_field or conf["header_timestamp_field"] |
| 36 | 41 | self._body_field = body_field or conf["body_timestamp_field"] |
| 37 | 42 | self._instance_field = instance_field or conf["instance_timestamp_field"] |
| 38 | 43 | self._noupdate_code = noupdate_code or conf["noupdate_code"] |
| 39 | 44 | self._missing_action = missing_action or conf["missing_action"] |
| 40 | - self._datetime_format = datetime_format or conf["datetime_format"] | |
| 41 | 45 | self.client_timestamp = client_timestamp |
| 42 | 46 | |
| 43 | 47 | if server_timestamp: |
| @@ -45,52 +49,45 @@ class TimeCheckPrivate: | ||
| 45 | 49 | elif self._instance_field and hasattr(instance, self._instance_field): |
| 46 | 50 | val = getattr(instance, self._instance_field) |
| 47 | 51 | if isinstance(val, dt.datetime): |
| 48 | - self.server_time = val | |
| 52 | + self.server_timestamp = val | |
| 49 | 53 | else: |
| 50 | 54 | raise InvalidServerDatetimeField( |
| 51 | 55 | self._instance_field or "No instance field", |
| 52 | 56 | instance, |
| 53 | - self._noupdate_code, | |
| 54 | 57 | ) |
| 55 | 58 | |
| 56 | 59 | 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) | |
| 59 | 62 | if header_str: |
| 60 | 63 | try: |
| 61 | - self.client_timestamp = dt.datetime.strptime( | |
| 62 | - header_str, self._datetime_format | |
| 63 | - ) | |
| 64 | + self.client_timestamp = parse_dt(header_str) | |
| 64 | 65 | except Exception: |
| 65 | - raise InvalidClientDatetimeField( | |
| 66 | - self._header_field, header_str, self._datetime_format, 400 | |
| 67 | - ) | |
| 66 | + raise InvalidClientDatetimeField(self._header_field, header_str) | |
| 68 | 67 | elif body_str: |
| 69 | 68 | try: |
| 70 | - self.client_timestamp = dt.datetime.strptime( | |
| 71 | - body_str, self._datetime_format | |
| 72 | - ) | |
| 69 | + self.client_timestamp = parse_dt(body_str) | |
| 73 | 70 | except Exception: |
| 74 | - raise InvalidClientDatetimeField( | |
| 75 | - self._body_field, body_str, self._datetime_format, 400 | |
| 76 | - ) | |
| 71 | + raise InvalidClientDatetimeField(self._body_field, body_str) | |
| 77 | 72 | |
| 78 | 73 | def check_get(self): |
| 79 | 74 | """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 | + ) | |
| 80 | 78 | if not self.client_timestamp: |
| 81 | 79 | if self._missing_action == "noupdate": |
| 82 | - raise NoUpdate(self.parent.method, self._noupdate_code) | |
| 80 | + raise NoUpdate(self.request.method, self._noupdate_code) | |
| 83 | 81 | 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) | |
| 85 | 83 | |
| 86 | - def check_update( | |
| 87 | - self, code: str | int | None = None, missing_action: MissingAction | None = None | |
| 88 | - ): | |
| 84 | + def check_update(self): | |
| 89 | 85 | """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 | + ) | |
| 92 | 89 | if not self.client_timestamp: |
| 93 | 90 | 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): | ||
| 17 | 17 | missing_action: MissingAction |
| 18 | 18 | """What do do when the client does not provide a timestamp""" |
| 19 | 19 | 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 |