irongit

Enhanced time synchronization for drf views.

tightened up the types and readme

huncholanehuncholaneauthored
parent 64d11bacommit 399ca3c44aa8d1ed770d8d0378b76e24eed8a0baBrowse files

7 files changed, +90 -125

+7-0LICENSE
@@ -0,0 +1,7 @@
1+Copyright 2025 huncholane
2+
3+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4+
5+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6+
7+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+65-96README.md
@@ -1,6 +1,6 @@
1-# PlusRequest
1+# TimeCheck
22
3-**PlusRequest** is a Django REST Framework (DRF) extension that enhances the standard `Request` with first-class support for timestamp-based synchronization and typed metadata access.
3+**TimeCheck** is a Django REST Framework (DRF) extension that wraps the standard `Request` with first-class support for timestamp-based synchronization.
44
55 It adds a structured way to compare client and server timestamps, detect when updates are needed, and manage API headers in a consistent, testable manner.
66
@@ -8,10 +8,8 @@ It adds a structured way to compare client and server timestamps, detect when up
88
99 ## Features
1010
11-- ✅ `PlusRequest`: subclass of DRF’s `Request` with timestamp parsing & comparison
12-- 🧠 `TimestampOp`: context-aware timestamp diff logic (`raise_get`, etc.)
13-- ⚙️ Configurable via `PlusRequestConf` (env vars or `settings.PLUSREQUEST_CONF`)
14-- 🧾 Typed request metadata with `Meta`, `PlusMeta`, and `DefaultMeta`
11+- 🧠 `TimeCheck`: context-aware timestamp diff logic (`raise_get`, etc.)
12+- ⚙️ Configurable via `TimeCheckConf` (env vars or `settings.TIMECHECK_CONF`)
1513 - 🔒 Built-in error handling for missing or invalid timestamps
1614
1715 ---
@@ -19,29 +17,9 @@ It adds a structured way to compare client and server timestamps, detect when up
1917 ## Installation
2018
2119 ```bash
22-pip install git+https://github.com/huncholane/django-plusrequest
20+pip install git+https://github.com/huncholane/django-timecheck
2321 ````
2422
25-Make sure your project has a valid `setup.py` or `pyproject.toml`.
26-
27----
28-
29-Add to middleware. It's safe to add anywhere in the middleware since it only effects typing,
30-and the functionality is lazy.
31-
32-```python
33-MIDDLEWARE = [
34- "django.middleware.security.SecurityMiddleware",
35- "django.contrib.sessions.middleware.SessionMiddleware",
36- "django.middleware.common.CommonMiddleware",
37- "django.middleware.csrf.CsrfViewMiddleware",
38- "django.contrib.auth.middleware.AuthenticationMiddleware",
39- "django.middleware.clickjacking.XFrameOptionsMiddleware",
40- "plusrequest.middleware.PlusRequestMiddleware",
41-]
42-
43-```
44-
4523 ---
4624
4725 ## Usage
@@ -49,103 +27,94 @@ MIDDLEWARE = [
4927 ### Example View
5028
5129 ```python
52-from plusrequest.request import PlusRequest
53-
54-def view(request: PlusRequest):
55- op = request.ts_builder(instance=model)
56- if op.client_is_newer:
57- return Response("No update needed.")
58- # else send new data
59- return Response(data)
30+from timecheck import TimeCheck
31+from rest_framework.request import Request
32+from rest_framework.response import Response
33+from rest_framework.views import APIView
34+from .models import MyModel
35+
36+class MyView(APIView):
37+ def get(request: Request, pk:int):
38+ instance = MyModel.objects.get(pk=pk)
39+ TimeCheck(request,instance).check_time() # throws rest framework exception
40+ ...
41+ return Response(data)
42+
43+ def put(request: Request, pk:int):
44+ instance = MyModel.objects.get(pk=pk)
45+ TimeCheck(request,instance).check_update() # throws rest framework exception
46+ ...
47+ return Response(data)
6048 ```
6149
6250 ---
6351
64-## Timestamp Comparison
52+### Parameters
6553
66-The `TimestampOp` class is accessed via `request.top_builder(...)`. It automatically:
54+TimeCheck(request, ...) has the following arguments:
6755
68-- Extracts server timestamps from a model instance
69-- Parses client timestamps from headers or body
70-- Validates datetime format
71-- Raises errors or returns status flags
56+- **request** `rest_framework.request.Request`: A request to extract the client timestamp from. (required)
57+- **instance** `models.Model`: The database instance to extract the server timestamp from using the instance field. (optional)
58+- **server_timestamp** `dt.datetime`: A manual server timestamp to override TimeCheck parsing.(optional)
59+- **client_timestamp** `dt.datetime`: A manual client timestamp to override TimeCheck parsing. (optional)
60+- **header_field** `str`: Field to find client timestamp in the header (defaults to config)
61+- **body_field** `str`: Field to find client timestamp in request body (defaults to config)
62+- **instance_field** `str`: Field to find server timestamp in database model (defaults to config)
63+- **missing_action**: `"noupdate" | "continue"` (defaults to config)
64+ - `noupdate` will raise a drf exception to indicate the view should stop early if the client does not provide a timestamp
65+ - `continue` allows the view to continue processing data when the client does not provide a timestamp
66+- **noupdate_code** `int` The response code for exceptions raised to indicate the view should stop processing
67+- **dt_fmt** `str` The datetime format used to normalize timestamps. Useful when the client and server use mismatched time depths (defaults to config)
7268
73-### Supported behaviors
69+### TimeCheck Methods
7470
75-- `raise_get`
76-- `raise_update`
71+- `check_get` Raises a `NoUpdate` exception when the client timestamp is newer than or equal to the server timestamp
72+- `raise_update` Raises a `NoUpdate` exception when the client timestamp is older than or equal to the server timestamp
7773
7874 ---
7975
8076 ## Configuration
8177
82-Set via environment variables or in `settings.PLUSREQUEST_CONF`:
83-
84-| Key | Default | Description |
85-| -------------------------------------- | --------------------- | ------------------------------------------------------------- |
86-| `PLUSREQUEST_HEADER_TIMESTAMP_FIELD` | `lastUpdated` | Header field used for client timestamp |
87-| `PLUSREQUEST_BODY_TIMESTAMP_FIELD` | `lastUpdated` | Body field used for client timestamp |
88-| `PLUSREQUEST_INSTANCE_TIMESTAMP_FIELD` | `lastUpdated` | Model field used for server timestamp |
89-| `PLUSREQUEST_NOUPDATE_CODE` | `418` | Error code raised when update is unnecessary |
90-| `PLUSREQUEST_MISSING_ACTION` | `noupdate` | Action if timestamp is missing (`error`, `allow`, `noupdate`) |
91-| `PLUSREQUEST_DATETIME_FORMAT` | `%Y-%m-%dT%H:%M:%S%z` | Timestamp parsing format |
78+TimeCheck is customizable through the django settings, env, and on a per usage basis.
9279
93----
94-
95-## Typed Metadata Access
96-
97-Use `Meta` for strongly-typed access to `request.META`.
80+### Django Settings Dictionairy Defaults
9881
9982 ```python
100-from plusrequest.meta import Meta
101-
102-def view(request: PlusRequest):
103- meta: Meta = request.META
104- user_agent = meta.get("HTTP_USER_AGENT")
105- ip = meta.get("HTTP_X_REAL_IP") or meta.get("REMOTE_ADDR")
83+TIMECHECK_CONF = {
84+ "body_field": "lastUpdated",
85+ "dt_fmt": "%Y-%m-%dT%H:%M:%S%z",
86+ "header_field": "lastUpdated",
87+ "instance_field": "lastUpdated",
88+ "missing_action": "noupdate",
89+ "noupdate_code": 418,
90+}
10691 ```
10792
108-### Meta Types
93+###
10994
110-- `DefaultMeta`: Core Django fields (e.g. `HTTP_HOST`, `REQUEST_METHOD`)
111-- `PlusMeta`: Common API headers (e.g. `HTTP_AUTHORIZATION`, `HTTP_X_APP_VERSION`)
112-- `Meta`: Union of both
95+Set via environment variables or in `settings.PLUSREQUEST_CONF`:
96+
97+| Key | Default | Description |
98+| -------------------------------------- | --------------------- | ------------------------------------------------------------- |
99+| `TIMECHECK_HEADER_FIELD` | `lastUpdated` | Header field used for client timestamp (str) |
100+| `TIMECHECK_BODY_FIELD` | `lastUpdated` | Body field used for client timestamp (str) |
101+| `TIMECHECK_INSTANCE_FIELD` | `lastUpdated` | Model field used for server timestamp (str) |
102+| `TIMECHECK_NOUPDATE_CODE` | `418` | Error code raised when update is unnecessary (int) |
103+| `TIMECHECK_MISSING_ACTION` | `noupdate` | Action if timestamp is missing (`continue`, `noupdate`) |
104+| `TIMECHECK_DT_FMT` | `%Y-%m-%dT%H:%M:%S%z` | Timestamp parsing format (str) |
113105
114106 ---
115107
116108 ## Exceptions
117109
118-- `InvalidClientDatetimeField`
119-- `InvalidServerDatetimeField`
120-- `NoUpdate`
110+- `InvalidClientDatetimeField (400)` When the client timestamp is found but cannot be parsed.
111+- `InvalidServerDatetimeField (500)` When the server cannot make up a timestamp.
112+- `NoUpdate (Custom)` Custom status code used to represent no update.
121113
122114 Raised when timestamps are missing, malformatted, or update should be skipped.
123115
124116 ---
125117
126-## Project Structure
127-
128-```
129-plusrequest/
130-├── request.py # PlusRequest class
131-├── timestamp_op.py # TimestampOp logic
132-├── meta.py # TypedDict for request.META
133-├── settings.py # Loads PlusRequestConf
134-├── types.py # Custom enums or aliases
135-```
136-
137----
138-
139118 ## License
140119
141-MIT License
142-
143-```
144-
145----
146-
147-Let me know if you want:
148-- Examples for writing tests with `PlusRequest`
149-- Sphinx or `mkdocs` setup
150-- DRF `APIView` integration patterns
151-```
120+This project is licensed under the [MIT License](LICENSE).
+4-7timecheck/settings.py
@@ -18,14 +18,11 @@ def getval(t: type, k: str, d: Any):
1818
1919 conf = TimeCheckConf(
2020 {
21- "body_timestamp_field": getval(str, "body_timestamp_field", "lastUpdated"),
22- "datetime_format": getval(str, "datetime_format", "%Y-%m-%dT%H:%M:%S%z"),
23- "header_timestamp_field": getval(str, "header_timestamp_field", "lastUpdated"),
24- "instance_timestamp_field": getval(
25- str, "instance_timestamp_field", "lastUpdated"
26- ),
21+ "body_field": getval(str, "body_field", "lastUpdated"),
22+ "dt_fmt": getval(str, "dt_fmt", "%Y-%m-%dT%H:%M:%S%z"),
23+ "header_field": getval(str, "header_field", "lastUpdated"),
24+ "instance_field": getval(str, "instance_field", "lastUpdated"),
2725 "missing_action": getval(str, "missing_action", "noupdate"),
2826 "noupdate_code": getval(int, "noupdate_code", 418),
29- "replace_with_z": getval(bool, "replace_with_z", True),
3027 }
3128 )
+4-1timecheck/tests.py
@@ -3,7 +3,10 @@ from rest_framework.test import APIClient
33 from django.test import TestCase
44 from example_app.models import Post
55 from timecheck.settings import conf
6-from timecheck.utils import fmt_dt
6+
7+
8+def fmt_dt(t: dt.datetime):
9+ return dt.datetime.strftime(t, conf["dt_fmt"])
710
811
912 class TimeCheckTests(TestCase):
+4-4timecheck/timecheck.py
@@ -30,12 +30,12 @@ class TimeCheckPrivate:
3030 instance: models.Model | None = None,
3131 server_timestamp: dt.datetime | None = None,
3232 client_timestamp: dt.datetime | None = None,
33- header_field=conf["header_timestamp_field"],
34- body_field=conf["body_timestamp_field"],
35- instance_field=conf["instance_timestamp_field"],
33+ header_field=conf["header_field"],
34+ body_field=conf["body_field"],
35+ instance_field=conf["instance_field"],
3636 missing_action: MissingAction = conf["missing_action"],
3737 noupdate_code=conf["noupdate_code"],
38- dt_fmt=conf["datetime_format"],
38+ dt_fmt=conf["dt_fmt"],
3939 ):
4040 self.request = request
4141 self._header_field = header_field
+5-5timecheck/types.py
@@ -6,15 +6,15 @@ MissingAction = Literal["continue", "noupdate"]
66 class TimeCheckConf(TypedDict):
77 """Dataclass for configuring TimeCheck"""
88
9- header_timestamp_field: str
9+ header_field: str
1010 """Field in headers to use for client timestamp"""
11- body_timestamp_field: str
11+ body_field: str
1212 """Field in request body to use for client timestamp"""
13- instance_timestamp_field: str
13+ instance_field: str
1414 """Field in model instance to use for server timestamp"""
1515 noupdate_code: int
1616 """Status code for when the client is newer on a GET or client is older on a POST"""
1717 missing_action: MissingAction
1818 """What do do when the client does not provide a timestamp"""
19- datetime_format: str
20- replace_with_z: bool
19+ dt_fmt: str
20+ """Format used to normalize datetimes."""
+1-12timecheck/utils.py
@@ -8,16 +8,5 @@ def parse_dt(s: str):
88 return serializers.DateTimeField().to_internal_value(s)
99
1010
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
20-
21-
22-def normalize_dt(t: dt.datetime, fmt=conf["datetime_format"]):
11+def normalize_dt(t: dt.datetime, fmt=conf["dt_fmt"]):
2312 return dt.datetime.strptime(t.strftime(fmt), fmt)