Enhanced time synchronization for drf views.
initial commit
17 files changed, +505 -0
+133-0README.md
| @@ -0,0 +1,133 @@ | ||
| 1 | +# PlusRequest | |
| 2 | + | |
| 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. | |
| 4 | + | |
| 5 | +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. | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## Features | |
| 10 | + | |
| 11 | +- ✅ `PlusRequest`: subclass of DRF’s `Request` with timestamp parsing & comparison | |
| 12 | +- 🧠 `TimestampOp`: context-aware timestamp diff logic (`client_is_newer`, etc.) | |
| 13 | +- ⚙️ Configurable via `PlusRequestConf` (env vars or `settings.PLUSREQUEST_CONF`) | |
| 14 | +- 🧾 Typed request metadata with `Meta`, `PlusMeta`, and `DefaultMeta` | |
| 15 | +- 🔒 Built-in error handling for missing or invalid timestamps | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## Installation | |
| 20 | + | |
| 21 | +```bash | |
| 22 | +pip install git+https://github.com/huncholane/django-plusrequest | |
| 23 | +```` | |
| 24 | + | |
| 25 | +Make sure your project has a valid `setup.py` or `pyproject.toml`. | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## Usage | |
| 30 | + | |
| 31 | +### Example View | |
| 32 | + | |
| 33 | +```python | |
| 34 | +from plusrequest.request import PlusRequest | |
| 35 | + | |
| 36 | +def view(request: PlusRequest): | |
| 37 | + op = request.ts_builder(instance=model) | |
| 38 | + if op.client_is_newer: | |
| 39 | + return Response("No update needed.") | |
| 40 | + # else send new data | |
| 41 | + return Response(data) | |
| 42 | +``` | |
| 43 | + | |
| 44 | +--- | |
| 45 | + | |
| 46 | +## Timestamp Comparison | |
| 47 | + | |
| 48 | +The `TimestampOp` class is accessed via `request.top_builder(...)`. It automatically: | |
| 49 | + | |
| 50 | +* Extracts server timestamps from a model instance | |
| 51 | +* Parses client timestamps from headers or body | |
| 52 | +* Validates datetime format | |
| 53 | +* Raises errors or returns status flags | |
| 54 | + | |
| 55 | +### Supported behaviors | |
| 56 | + | |
| 57 | +* `raise_get` | |
| 58 | +* `raise_update` | |
| 59 | + | |
| 60 | +--- | |
| 61 | + | |
| 62 | +## Configuration | |
| 63 | + | |
| 64 | +Set via environment variables or in `settings.PLUSREQUEST_CONF`: | |
| 65 | + | |
| 66 | +| Key | Default | Description | | |
| 67 | +| -------------------------------------- | --------------------- | ------------------------------------------------------------- | | |
| 68 | +| `PLUSREQUEST_HEADER_TIMESTAMP_FIELD` | `lastUpdated` | Header field used for client timestamp | | |
| 69 | +| `PLUSREQUEST_BODY_TIMESTAMP_FIELD` | `lastUpdated` | Body field used for client timestamp | | |
| 70 | +| `PLUSREQUEST_INSTANCE_TIMESTAMP_FIELD` | `lastUpdated` | Model field used for server timestamp | | |
| 71 | +| `PLUSREQUEST_NOUPDATE_CODE` | `418` | Error code raised when update is unnecessary | | |
| 72 | +| `PLUSREQUEST_MISSING_ACTION` | `noupdate` | Action if timestamp is missing (`error`, `allow`, `noupdate`) | | |
| 73 | +| `PLUSREQUEST_DATETIME_FORMAT` | `%Y-%m-%dT%H:%M:%S%z` | Timestamp parsing format | | |
| 74 | + | |
| 75 | +--- | |
| 76 | + | |
| 77 | +## Typed Metadata Access | |
| 78 | + | |
| 79 | +Use `Meta` for strongly-typed access to `request.META`. | |
| 80 | + | |
| 81 | +```python | |
| 82 | +from plusrequest.meta import Meta | |
| 83 | + | |
| 84 | +def view(request: PlusRequest): | |
| 85 | + meta: Meta = request.META | |
| 86 | + user_agent = meta.get("HTTP_USER_AGENT") | |
| 87 | + ip = meta.get("HTTP_X_REAL_IP") or meta.get("REMOTE_ADDR") | |
| 88 | +``` | |
| 89 | + | |
| 90 | +### Meta Types | |
| 91 | + | |
| 92 | +* `DefaultMeta`: Core Django fields (e.g. `HTTP_HOST`, `REQUEST_METHOD`) | |
| 93 | +* `PlusMeta`: Common API headers (e.g. `HTTP_AUTHORIZATION`, `HTTP_X_APP_VERSION`) | |
| 94 | +* `Meta`: Union of both | |
| 95 | + | |
| 96 | +--- | |
| 97 | + | |
| 98 | +## Exceptions | |
| 99 | + | |
| 100 | +* `InvalidClientDatetimeField` | |
| 101 | +* `InvalidServerDatetimeField` | |
| 102 | +* `NoUpdate` | |
| 103 | + | |
| 104 | +Raised when timestamps are missing, malformatted, or update should be skipped. | |
| 105 | + | |
| 106 | +--- | |
| 107 | + | |
| 108 | +## Project Structure | |
| 109 | + | |
| 110 | +``` | |
| 111 | +plusrequest/ | |
| 112 | +├── request.py # PlusRequest class | |
| 113 | +├── timestamp_op.py # TimestampOp logic | |
| 114 | +├── meta.py # TypedDict for request.META | |
| 115 | +├── settings.py # Loads PlusRequestConf | |
| 116 | +├── types.py # Custom enums or aliases | |
| 117 | +``` | |
| 118 | + | |
| 119 | +--- | |
| 120 | + | |
| 121 | +## License | |
| 122 | + | |
| 123 | +MIT License | |
| 124 | + | |
| 125 | +``` | |
| 126 | + | |
| 127 | +--- | |
| 128 | + | |
| 129 | +Let me know if you want: | |
| 130 | +- Examples for writing tests with `PlusRequest` | |
| 131 | +- Sphinx or `mkdocs` setup | |
| 132 | +- DRF `APIView` integration patterns | |
| 133 | +``` |
+8-0django_plusrequest.egg-info/PKG-INFO
| @@ -0,0 +1,8 @@ | ||
| 1 | +Metadata-Version: 2.4 | |
| 2 | +Name: django-plusrequest | |
| 3 | +Version: 0.1.0 | |
| 4 | +Summary: Middleware to add more functionality to request objects and improve type hints. | |
| 5 | +Project-URL: Homepage, https://github.com/huncholane/django-smartwatch | |
| 6 | +Requires-Python: >=3.8 | |
| 7 | +Description-Content-Type: text/markdown | |
| 8 | +Requires-Dist: django |
+17-0django_plusrequest.egg-info/SOURCES.txt
| @@ -0,0 +1,17 @@ | ||
| 1 | +pyproject.toml | |
| 2 | +django_plusrequest.egg-info/PKG-INFO | |
| 3 | +django_plusrequest.egg-info/SOURCES.txt | |
| 4 | +django_plusrequest.egg-info/dependency_links.txt | |
| 5 | +django_plusrequest.egg-info/requires.txt | |
| 6 | +django_plusrequest.egg-info/top_level.txt | |
| 7 | +plusrequest/__init__.py | |
| 8 | +plusrequest/apps.py | |
| 9 | +plusrequest/exceptions.py | |
| 10 | +plusrequest/meta.py | |
| 11 | +plusrequest/middleware.py | |
| 12 | +plusrequest/request.py | |
| 13 | +plusrequest/settings.py | |
| 14 | +plusrequest/timestamp_op.py | |
| 15 | +plusrequest/types.py | |
| 16 | +plusrequest/views.py | |
| 17 | +plusrequest/migrations/__init__.py |
+1-0django_plusrequest.egg-info/dependency_links.txt
| @@ -0,0 +1 @@ | ||
| 1 | + |
+1-0django_plusrequest.egg-info/requires.txt
| @@ -0,0 +1 @@ | ||
| 1 | +django |
+1-0django_plusrequest.egg-info/top_level.txt
| @@ -0,0 +1 @@ | ||
| 1 | +plusrequest |
+0-0plusrequest/__init__.py
No content changes (mode or rename only).
+6-0plusrequest/apps.py
| @@ -0,0 +1,6 @@ | ||
| 1 | +from django.apps import AppConfig | |
| 2 | + | |
| 3 | + | |
| 4 | +class PlusrequestConfig(AppConfig): | |
| 5 | + default_auto_field = 'django.db.models.BigAutoField' | |
| 6 | + name = 'plusrequest' |
+31-0plusrequest/exceptions.py
| @@ -0,0 +1,31 @@ | ||
| 1 | +from typing import Any | |
| 2 | +from rest_framework import exceptions | |
| 3 | + | |
| 4 | + | |
| 5 | +class InvalidServerDatetimeField(exceptions.APIException): | |
| 6 | + def __init__(self, field_name: str, val: Any, code: str | int): | |
| 7 | + super().__init__( | |
| 8 | + f"{field_name} is not a valid datetime. {val} is a {type(val)}", str(code) | |
| 9 | + ) | |
| 10 | + | |
| 11 | + | |
| 12 | +class InvalidClientDatetimeField(exceptions.APIException): | |
| 13 | + def __init__(self, field_name: str, val: str, fmt: str, code: str | int): | |
| 14 | + code = str(code) | |
| 15 | + super().__init__( | |
| 16 | + f"{field_name} header {val} val is unable to be parsed with {fmt}", | |
| 17 | + code, | |
| 18 | + ) | |
| 19 | + | |
| 20 | + | |
| 21 | +class NoUpdate(exceptions.APIException): | |
| 22 | + def __init__(self, request_method: str | None, code: str | int) -> None: | |
| 23 | + code = str(code) | |
| 24 | + if request_method in ["POST", "PUT"]: | |
| 25 | + super().__init__( | |
| 26 | + "Client has submitted older data than the server. Skipping update", code | |
| 27 | + ) | |
| 28 | + elif request_method == "GET": | |
| 29 | + super().__init__("Client is already up to date", code) | |
| 30 | + else: | |
| 31 | + super().__init__("No update", code) |
+107-0plusrequest/meta.py
| @@ -0,0 +1,107 @@ | ||
| 1 | +from typing import TypedDict | |
| 2 | + | |
| 3 | + | |
| 4 | +class DefaultMeta(TypedDict, total=False): | |
| 5 | + """The default meta items in a django request.""" | |
| 6 | + | |
| 7 | + CONTENT_LENGTH: str | |
| 8 | + """The length of the request body (as a string).""" | |
| 9 | + CONTENT_TYPE: str | |
| 10 | + """The MIME type of the request body.""" | |
| 11 | + HTTP_ACCEPT: str | |
| 12 | + """Acceptable content types for the response.""" | |
| 13 | + HTTP_ACCEPT_ENCODING: str | |
| 14 | + """Acceptable encodings for the response.""" | |
| 15 | + HTTP_ACCEPT_LANGUAGE: str | |
| 16 | + """Acceptable languages for the response.""" | |
| 17 | + HTTP_HOST: str | |
| 18 | + """The HTTP Host header sent by the client.""" | |
| 19 | + HTTP_REFERER: str | |
| 20 | + """The referring page, if any.""" | |
| 21 | + HTTP_USER_AGENT: str | |
| 22 | + """The client’s user-agent string.""" | |
| 23 | + QUERY_STRING: str | |
| 24 | + """The query string, as a single (unparsed) string.""" | |
| 25 | + REMOTE_ADDR: str | |
| 26 | + """The IP address of the client.""" | |
| 27 | + REMOTE_HOST: str | |
| 28 | + """The hostname of the client.""" | |
| 29 | + REMOTE_USER: str | |
| 30 | + """The user authenticated by the web server, if any.""" | |
| 31 | + REQUEST_METHOD: str | |
| 32 | + """A string such as "GET" or "POST".""" | |
| 33 | + SERVER_NAME: str | |
| 34 | + """The hostname of the server.""" | |
| 35 | + SERVER_PORT: str | |
| 36 | + | |
| 37 | + | |
| 38 | +class DefaultHeaders(TypedDict, total=False): | |
| 39 | + """Headers from the default meta""" | |
| 40 | + | |
| 41 | + accept: str | |
| 42 | + """Acceptable content types for the response.""" | |
| 43 | + accept_encoding: str | |
| 44 | + """Acceptable encodings for the response.""" | |
| 45 | + HTTP_ACCEPT_LANGUAGE: str | |
| 46 | + """Acceptable languages for the response.""" | |
| 47 | + HTTP_HOST: str | |
| 48 | + """The HTTP Host header sent by the client.""" | |
| 49 | + HTTP_REFERER: str | |
| 50 | + """The referring page, if any.""" | |
| 51 | + HTTP_USER_AGENT: str | |
| 52 | + """The client’s user-agent string.""" | |
| 53 | + | |
| 54 | + | |
| 55 | +class PlusMeta(TypedDict, total=False): | |
| 56 | + """Optional headers commonly used in extended HTTP request metadata.""" | |
| 57 | + | |
| 58 | + HTTP_AUTHORIZATION: str | None | |
| 59 | + """Standard header for bearer tokens, API keys, basic auth, etc.""" | |
| 60 | + HTTP_X_API_KEY: str | None | |
| 61 | + """Custom API key used instead of Authorization.""" | |
| 62 | + HTTP_API_KEY: str | None | |
| 63 | + """Alternate spelling, seen in some legacy systems.""" | |
| 64 | + HTTP_X_SESSION_ID: str | None | |
| 65 | + """Session identifier, often used in mobile apps or sticky auth.""" | |
| 66 | + HTTP_SESSION: str | None | |
| 67 | + """Alternate field for session ID.""" | |
| 68 | + HTTP_X_CLIENT_ID: str | None | |
| 69 | + """Identifies the application or user making the request.""" | |
| 70 | + HTTP_X_CLIENT_SECRET: str | None | |
| 71 | + """Used in OAuth-style flows to authenticate the client.""" | |
| 72 | + HTTP_X_APP_TOKEN: str | None | |
| 73 | + """App-level token separate from user access.""" | |
| 74 | + HTTP_X_REFRESH_TOKEN: str | None | |
| 75 | + """Used for token refresh flows in OAuth 2.0 systems.""" | |
| 76 | + HTTP_X_TOKEN: str | None | |
| 77 | + """A token for api request""" | |
| 78 | + HTTP_TOKEN: str | None | |
| 79 | + """A token for api request""" | |
| 80 | + HTTP_LASTUPDATED: str | None | |
| 81 | + """Custom header indicating the client's last update timestamp (e.g., ISO 8601).""" | |
| 82 | + HTTP_IF_NONE_MATCH: str | None | |
| 83 | + """ETag validator for conditional requests (used for caching).""" | |
| 84 | + HTTP_IF_MODIFIED_SINCE: str | None | |
| 85 | + """Timestamp used to check if the resource has changed since the client's last version.""" | |
| 86 | + HTTP_X_REQUEST_ID: str | None | |
| 87 | + """Unique identifier for tracing requests across services or logs.""" | |
| 88 | + HTTP_X_REAL_IP: str | None | |
| 89 | + """Actual client IP address, typically added by a reverse proxy.""" | |
| 90 | + HTTP_X_FORWARDED_FOR: str | None | |
| 91 | + """Comma-separated list of IPs forwarded through proxies (first is original client).""" | |
| 92 | + HTTP_X_DEVICE_ID: str | None | |
| 93 | + """Custom header identifying the client's device (mobile or desktop apps).""" | |
| 94 | + HTTP_X_PLATFORM: str | None | |
| 95 | + """Platform identifier such as 'ios', 'android', 'web', etc.""" | |
| 96 | + HTTP_X_APP_VERSION: str | None | |
| 97 | + """Version string of the client app, useful for feature gating or debugging.""" | |
| 98 | + HTTP_ORIGIN: str | None | |
| 99 | + """Origin of the request, used in CORS validation.""" | |
| 100 | + HTTP_DNT: str | None | |
| 101 | + """Do Not Track header, indicating user's tracking preference ('1' or '0').""" | |
| 102 | + HTTP_SEC_CH_UA: str | None | |
| 103 | + """User-Agent Client Hint header, sent by modern browsers like Chrome.""" | |
| 104 | + | |
| 105 | + | |
| 106 | +class Meta(DefaultMeta, PlusMeta): | |
| 107 | + """A typed dict for the default meta items in django as well as some other common header fields.""" |
+23-0plusrequest/middleware.py
| @@ -0,0 +1,23 @@ | ||
| 1 | +from rest_framework.request import Request | |
| 2 | +from plusrequest.request import PlusRequest | |
| 3 | + | |
| 4 | + | |
| 5 | +class PlusRequestMiddleware: | |
| 6 | + def __init__(self, get_response): | |
| 7 | + self.get_response = get_response | |
| 8 | + # One-time configuration and initialization. | |
| 9 | + | |
| 10 | + def __call__(self, request: Request): | |
| 11 | + # Code to be executed for each request before | |
| 12 | + # the view (and later middleware) are called. | |
| 13 | + | |
| 14 | + # Add header device date functionality to request | |
| 15 | + request = PlusRequest._promote_drf_to_plus_request(request) | |
| 16 | + | |
| 17 | + # Ensure the member shortcut exists | |
| 18 | + response = self.get_response(request) | |
| 19 | + | |
| 20 | + # Code to be executed for each request/response after | |
| 21 | + # the view is called. | |
| 22 | + | |
| 23 | + return response |
+0-0plusrequest/migrations/__init__.py
No content changes (mode or rename only).
+31-0plusrequest/request.py
| @@ -0,0 +1,31 @@ | ||
| 1 | +from typing import ( | |
| 2 | + TYPE_CHECKING, | |
| 3 | + cast, | |
| 4 | +) | |
| 5 | +from rest_framework.request import Request | |
| 6 | + | |
| 7 | +from plusrequest.meta import Meta | |
| 8 | +from plusrequest.timestamp_op import TimestampOp | |
| 9 | + | |
| 10 | + | |
| 11 | +class PlusRequest(Request): | |
| 12 | + if TYPE_CHECKING: | |
| 13 | + META: Meta | |
| 14 | + | |
| 15 | + @property | |
| 16 | + def top_builder(self): | |
| 17 | + """A property that runs the `__call__` method for `TimestampOp` which initializes from kwargs first, then `PlusRequest` app settings. | |
| 18 | + | |
| 19 | + ```python | |
| 20 | + def view(request: PlusRequest): | |
| 21 | + if request.top_builder(<configure>).client_is_newer: | |
| 22 | + return Response("Client doesn't need anything") | |
| 23 | + ... | |
| 24 | + return Response(data) | |
| 25 | + ```""" | |
| 26 | + return TimestampOp(self) | |
| 27 | + | |
| 28 | + @classmethod | |
| 29 | + def _promote_drf_to_plus_request(cls, request: Request): | |
| 30 | + request.__class__ = cls | |
| 31 | + return cast(PlusRequest, request) |
+32-0plusrequest/settings.py
| @@ -0,0 +1,32 @@ | ||
| 1 | +from dataclasses import dataclass | |
| 2 | +from environs import env as e | |
| 3 | +from django.conf import settings | |
| 4 | + | |
| 5 | +from plusrequest.types import MissingAction | |
| 6 | + | |
| 7 | + | |
| 8 | +def env(type: type, key: str, default: str): | |
| 9 | + return getattr(e, str(type))(f"PLUSREQUEST_{key}", default) | |
| 10 | + | |
| 11 | + | |
| 12 | +@dataclass() | |
| 13 | +class PlusRequestConf: | |
| 14 | + """Dataclass for configuring PlusRequest""" | |
| 15 | + | |
| 16 | + header_timestamp_field: str = env(str, "HEADER_TIMESTAMP_FIELD", "lastUpdated") | |
| 17 | + """Field in headers to use for client timestamp""" | |
| 18 | + body_timestamp_field: str = env(str, "BODY_TIMESTAMP_FIELD", "lastUpdated") | |
| 19 | + """Field in request body to use for client timestamp""" | |
| 20 | + instance_timestamp_field: str = env(str, "INSTANCE_TIMESTAMP_FIELD", "lastUpdated") | |
| 21 | + """Field in model instance to use for server timestamp""" | |
| 22 | + noupdate_code: str = env(str, "NOUPDATE_CODE", "418") | |
| 23 | + """Status code for when the client is newer on a GET or client is older on a POST""" | |
| 24 | + missing_action: MissingAction = env(str, "MISSING_ACTION", "noupdate") | |
| 25 | + """What do do when the client does not provide a timestamp""" | |
| 26 | + datetime_format: str = env(str, "DATETIME_FORMAT", "%Y-%m-%dT%H:%M:%S%z") | |
| 27 | + | |
| 28 | + | |
| 29 | +if hasattr(settings, "PLUSREQUEST_CONF"): | |
| 30 | + conf: PlusRequestConf = settings.PLUSREQUEST_CONF | |
| 31 | +else: | |
| 32 | + conf = PlusRequestConf() |
+96-0plusrequest/timestamp_op.py
| @@ -0,0 +1,96 @@ | ||
| 1 | +import datetime as dt | |
| 2 | +from typing import TYPE_CHECKING | |
| 3 | +from django.db import models | |
| 4 | + | |
| 5 | +from plusrequest.exceptions import ( | |
| 6 | + InvalidClientDatetimeField, | |
| 7 | + InvalidServerDatetimeField, | |
| 8 | + NoUpdate, | |
| 9 | +) | |
| 10 | +from plusrequest.settings import conf | |
| 11 | +from plusrequest.types import MissingAction | |
| 12 | + | |
| 13 | +if TYPE_CHECKING: | |
| 14 | + from plusrequest.request import PlusRequest | |
| 15 | + | |
| 16 | + | |
| 17 | +class TimestampOp: | |
| 18 | + """Builds a set of instructions on how to interpret client and server timestamps. | |
| 19 | + Initialization will always raise an error if the server timestamp is missing. | |
| 20 | + Errors for missing client timestamps can be configured.""" | |
| 21 | + | |
| 22 | + _missing_action: MissingAction | |
| 23 | + | |
| 24 | + def __init__(self, parent: "PlusRequest"): | |
| 25 | + self.parent = parent | |
| 26 | + | |
| 27 | + def __call__( | |
| 28 | + self, | |
| 29 | + instance: models.Model | None = None, | |
| 30 | + server_timestamp: dt.datetime | None = None, | |
| 31 | + client_timestamp: dt.datetime | None = None, | |
| 32 | + header_field: str | None = None, | |
| 33 | + body_field: str | None = None, | |
| 34 | + instance_field: str | None = None, | |
| 35 | + missing_action: MissingAction | None = None, | |
| 36 | + noupdate_code: str | None = None, | |
| 37 | + datetime_format: str | None = None, | |
| 38 | + ): | |
| 39 | + self._header_field = header_field or conf.header_timestamp_field | |
| 40 | + self._body_field = body_field or conf.body_timestamp_field | |
| 41 | + self._instance_field = instance_field or conf.instance_timestamp_field | |
| 42 | + self._noupdate_code = noupdate_code or conf.noupdate_code | |
| 43 | + self._missing_action = missing_action or conf.missing_action | |
| 44 | + self._datetime_format = datetime_format or conf.datetime_format | |
| 45 | + self.client_timestamp = client_timestamp | |
| 46 | + | |
| 47 | + if not server_timestamp and hasattr(instance, self._instance_field): | |
| 48 | + self.server_timestamp = getattr(instance, self._instance_field) | |
| 49 | + if not isinstance(server_timestamp, dt.datetime): | |
| 50 | + raise InvalidServerDatetimeField( | |
| 51 | + self._instance_field, server_timestamp, 500 | |
| 52 | + ) | |
| 53 | + | |
| 54 | + if not self.client_timestamp: | |
| 55 | + header_str = self.parent.headers.get(self._header_field, None) | |
| 56 | + body_str = self.parent.data.get(self._body_field, None) | |
| 57 | + if header_str: | |
| 58 | + try: | |
| 59 | + self.client_timestamp = dt.datetime.strptime( | |
| 60 | + header_str, self._datetime_format | |
| 61 | + ) | |
| 62 | + except Exception: | |
| 63 | + raise InvalidClientDatetimeField( | |
| 64 | + self._header_field, header_str, self._datetime_format, 400 | |
| 65 | + ) | |
| 66 | + elif body_str: | |
| 67 | + try: | |
| 68 | + self.client_timestamp = dt.datetime.strptime( | |
| 69 | + body_str, self._datetime_format | |
| 70 | + ) | |
| 71 | + except Exception: | |
| 72 | + raise InvalidClientDatetimeField( | |
| 73 | + self._body_field, body_str, self._datetime_format, 400 | |
| 74 | + ) | |
| 75 | + | |
| 76 | + def raise_get( | |
| 77 | + self, code: str | int | None = None, missing_action: MissingAction | None = None | |
| 78 | + ): | |
| 79 | + """Raises a drf exception `NoUpdate` which provides details that there is no need to give data back to the user.""" | |
| 80 | + code = code or self._noupdate_code | |
| 81 | + missing_action = missing_action or self._missing_action | |
| 82 | + if not self.client_timestamp and missing_action == "noupdate": | |
| 83 | + raise NoUpdate(self.parent.method, code) | |
| 84 | + if self.client_timestamp >= self.server_timestamp: | |
| 85 | + raise NoUpdate(self.parent.method, code) | |
| 86 | + | |
| 87 | + def raise_update( | |
| 88 | + self, code: str | int | None = None, missing_action: MissingAction | None = None | |
| 89 | + ): | |
| 90 | + """Raises a drf exception `NoUpdate` which provides details that there is no need to update the server.""" | |
| 91 | + code = code or self._noupdate_code | |
| 92 | + missing_action = missing_action or self._missing_action | |
| 93 | + if not self.client_timestamp and missing_action == "noupdate": | |
| 94 | + raise NoUpdate(self.parent.method, code) | |
| 95 | + if self.client_timestamp < self.server_timestamp: | |
| 96 | + raise NoUpdate(self.parent.method, code) |
+4-0plusrequest/types.py
| @@ -0,0 +1,4 @@ | ||
| 1 | +from typing import Literal | |
| 2 | + | |
| 3 | + | |
| 4 | +MissingAction = Literal["continue", "noupdate"] |
+14-0pyproject.toml
| @@ -0,0 +1,14 @@ | ||
| 1 | +[project] | |
| 2 | +name = "django-plusrequest" | |
| 3 | +version = "0.1.0" | |
| 4 | +description = "Middleware to add more functionality to request objects and improve type hints." | |
| 5 | +readme = "README.md" | |
| 6 | +requires-python = ">=3.8" | |
| 7 | +dependencies = ["django"] | |
| 8 | + | |
| 9 | +[project.urls] | |
| 10 | +Homepage = "https://github.com/huncholane/django-smartwatch" | |
| 11 | + | |
| 12 | +[build-system] | |
| 13 | +requires = ["setuptools"] | |
| 14 | +build-backend = "setuptools.build_meta" |