Enhanced time synchronization for drf views.
set up examples
24 files changed, +355 -44
+2-27.gitignore
| @@ -1,29 +1,4 @@ | ||
| 1 | -# Bytecode | |
| 2 | -*.py[cod] | |
| 3 | -__pycache__/ | |
| 4 | - | |
| 5 | -# Environments | |
| 6 | -.env | |
| 7 | -.venv/ | |
| 8 | -venv/ | |
| 9 | - | |
| 10 | -# Editor | |
| 11 | -.vscode/ | |
| 12 | -.idea/ | |
| 13 | -*.swp | |
| 14 | - | |
| 15 | -# Build artifacts | |
| 16 | -*.egg-info/ | |
| 17 | -dist/ | |
| 18 | 1 | build/ |
| 19 | - | |
| 20 | -# Django | |
| 21 | -db.sqlite3 | |
| 22 | -*.log | |
| 23 | - | |
| 24 | -# Tests | |
| 25 | -.coverage | |
| 26 | -.pytest_cache/ | |
| 27 | - | |
| 28 | -/app | |
| 2 | +*.egg-info/ | |
| 3 | +__pycache__/ | |
| 29 | 4 |
+0-0db.sqlite3
Binary file changed.
+0-0example_app/__init__.py
No content changes (mode or rename only).
+8-0example_app/admin.py
| @@ -0,0 +1,8 @@ | ||
| 1 | +from django.contrib import admin | |
| 2 | + | |
| 3 | +from example_app.models import Post | |
| 4 | + | |
| 5 | + | |
| 6 | +@admin.register(Post) | |
| 7 | +class PostAdmin(admin.ModelAdmin): | |
| 8 | + list_display = ["lastUpdated", "text"] |
+17-0example_app/apps.py
| @@ -0,0 +1,17 @@ | ||
| 1 | +from django.apps import AppConfig | |
| 2 | +from django.core.management import call_command | |
| 3 | +from django.db.models.signals import post_migrate | |
| 4 | + | |
| 5 | + | |
| 6 | +class ExampleAppConfig(AppConfig): | |
| 7 | + default_auto_field = "django.db.models.BigAutoField" | |
| 8 | + name = "example_app" | |
| 9 | + | |
| 10 | + def ready(self): | |
| 11 | + print("HELLO") | |
| 12 | + post_migrate.connect(load_fixtures, sender=self) | |
| 13 | + | |
| 14 | + | |
| 15 | +def load_fixtures(**_): | |
| 16 | + print("Making init data") | |
| 17 | + call_command("loaddata", "initial_data", app_label="example_app", verbosity=0) |
+30-0example_app/fixtures/initial_data.json
| @@ -0,0 +1,30 @@ | ||
| 1 | +[ | |
| 2 | + { | |
| 3 | + "model": "auth.user", | |
| 4 | + "pk": 1, | |
| 5 | + "fields": { | |
| 6 | + "username": "admin", | |
| 7 | + "password": "pbkdf2_sha256$1000000$98yGPuJFQz9A7CNYptr34U$QvbT91v0BxBzjtuD7ILuf+RG0B7ULTvdNsNXWWpcwNY=", | |
| 8 | + "is_superuser": true, | |
| 9 | + "is_staff": true, | |
| 10 | + "is_active": true, | |
| 11 | + "email": "admin@example.com" | |
| 12 | + } | |
| 13 | + }, | |
| 14 | + { | |
| 15 | + "model": "example_app.post", | |
| 16 | + "pk": 1, | |
| 17 | + "fields": { | |
| 18 | + "lastUpdated": "2024-01-01T12:00:00Z", | |
| 19 | + "text": "Hello world!" | |
| 20 | + } | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + "model": "example_app.post", | |
| 24 | + "pk": 2, | |
| 25 | + "fields": { | |
| 26 | + "lastUpdated": "2024-01-02T15:00:00Z", | |
| 27 | + "text": "Second post" | |
| 28 | + } | |
| 29 | + } | |
| 30 | +] |
+22-0example_app/migrations/0001_initial.py
| @@ -0,0 +1,22 @@ | ||
| 1 | +# Generated by Django 5.2.1 on 2025-06-03 16:37 | |
| 2 | + | |
| 3 | +from django.db import migrations, models | |
| 4 | + | |
| 5 | + | |
| 6 | +class Migration(migrations.Migration): | |
| 7 | + | |
| 8 | + initial = True | |
| 9 | + | |
| 10 | + dependencies = [ | |
| 11 | + ] | |
| 12 | + | |
| 13 | + operations = [ | |
| 14 | + migrations.CreateModel( | |
| 15 | + name='Post', | |
| 16 | + fields=[ | |
| 17 | + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | |
| 18 | + ('lastUpdated', models.DateTimeField()), | |
| 19 | + ('text', models.TextField()), | |
| 20 | + ], | |
| 21 | + ), | |
| 22 | + ] |
+0-0example_app/migrations/__init__.py
No content changes (mode or rename only).
+6-0example_app/models.py
| @@ -0,0 +1,6 @@ | ||
| 1 | +from django.db import models | |
| 2 | + | |
| 3 | + | |
| 4 | +class Post(models.Model): | |
| 5 | + lastUpdated = models.DateTimeField() | |
| 6 | + text = models.TextField() |
+3-0example_app/tests.py
| @@ -0,0 +1,3 @@ | ||
| 1 | +from django.test import TestCase | |
| 2 | + | |
| 3 | +# Create your tests here. |
+5-0example_app/urls.py
| @@ -0,0 +1,5 @@ | ||
| 1 | +from django.urls import path | |
| 2 | + | |
| 3 | +from example_app.views import View | |
| 4 | + | |
| 5 | +urlpatterns = [path("", View.as_view())] |
+25-0example_app/views.py
| @@ -0,0 +1,25 @@ | ||
| 1 | +from typing import Union | |
| 2 | +from rest_framework.request import Request | |
| 3 | +from rest_framework.response import Response | |
| 4 | +from example_app.models import Post | |
| 5 | +from plusrequest.request import PlusRequest | |
| 6 | +from rest_framework.views import APIView | |
| 7 | + | |
| 8 | + | |
| 9 | +class View(APIView): | |
| 10 | + def get(self, request: PlusRequest): | |
| 11 | + instance = Post.objects.all().first() | |
| 12 | + if instance: | |
| 13 | + request.top_builder(instance=instance).raise_get() | |
| 14 | + return Response( | |
| 15 | + { | |
| 16 | + "CONTENT_LENGTH": request.META.get("CONTENT_LENGTH"), | |
| 17 | + "HTTP_ACCEPT": request.META.get("HTTP_ACCEPT"), | |
| 18 | + } | |
| 19 | + ) | |
| 20 | + | |
| 21 | + def put(self, request: PlusRequest): | |
| 22 | + instance = Post.objects.filter(request.data.get("id", None)).first() | |
| 23 | + if instance: | |
| 24 | + request.top_builder(instance=instance).raise_update() | |
| 25 | + return Response({"port": request.META.get("SERVER_PORT")}) |
+0-0example_project/__init__.py
No content changes (mode or rename only).
+16-0example_project/asgi.py
| @@ -0,0 +1,16 @@ | ||
| 1 | +""" | |
| 2 | +ASGI config for example project. | |
| 3 | + | |
| 4 | +It exposes the ASGI callable as a module-level variable named ``application``. | |
| 5 | + | |
| 6 | +For more information on this file, see | |
| 7 | +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ | |
| 8 | +""" | |
| 9 | + | |
| 10 | +import os | |
| 11 | + | |
| 12 | +from django.core.asgi import get_asgi_application | |
| 13 | + | |
| 14 | +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") | |
| 15 | + | |
| 16 | +application = get_asgi_application() |
+22-0example_project/migrations/0001_initial.py
| @@ -0,0 +1,22 @@ | ||
| 1 | +# Generated by Django 5.2.1 on 2025-06-03 16:04 | |
| 2 | + | |
| 3 | +from django.db import migrations, models | |
| 4 | + | |
| 5 | + | |
| 6 | +class Migration(migrations.Migration): | |
| 7 | + | |
| 8 | + initial = True | |
| 9 | + | |
| 10 | + dependencies = [ | |
| 11 | + ] | |
| 12 | + | |
| 13 | + operations = [ | |
| 14 | + migrations.CreateModel( | |
| 15 | + name='Post', | |
| 16 | + fields=[ | |
| 17 | + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | |
| 18 | + ('lastUpdated', models.DateTimeField()), | |
| 19 | + ('text', models.TextField()), | |
| 20 | + ], | |
| 21 | + ), | |
| 22 | + ] |
+0-0example_project/migrations/__init__.py
No content changes (mode or rename only).
+126-0example_project/settings.py
| @@ -0,0 +1,126 @@ | ||
| 1 | +""" | |
| 2 | +Django settings for example project. | |
| 3 | + | |
| 4 | +Generated by 'django-admin startproject' using Django 5.2.1. | |
| 5 | + | |
| 6 | +For more information on this file, see | |
| 7 | +https://docs.djangoproject.com/en/5.2/topics/settings/ | |
| 8 | + | |
| 9 | +For the full list of settings and their values, see | |
| 10 | +https://docs.djangoproject.com/en/5.2/ref/settings/ | |
| 11 | +""" | |
| 12 | + | |
| 13 | +from pathlib import Path | |
| 14 | + | |
| 15 | +# Build paths inside the project like this: BASE_DIR / 'subdir'. | |
| 16 | +BASE_DIR = Path(__file__).resolve().parent.parent | |
| 17 | + | |
| 18 | + | |
| 19 | +# Quick-start development settings - unsuitable for production | |
| 20 | +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ | |
| 21 | + | |
| 22 | +# SECURITY WARNING: keep the secret key used in production secret! | |
| 23 | +SECRET_KEY = "django-insecure-=%r#z55prje)c3*m*e(xim*&k3)4m=4lqljp)4j)k+5ppk(fz6" | |
| 24 | + | |
| 25 | +# SECURITY WARNING: don't run with debug turned on in production! | |
| 26 | +DEBUG = True | |
| 27 | + | |
| 28 | +ALLOWED_HOSTS = [] | |
| 29 | + | |
| 30 | + | |
| 31 | +# Application definition | |
| 32 | + | |
| 33 | +INSTALLED_APPS = [ | |
| 34 | + "django.contrib.admin", | |
| 35 | + "django.contrib.auth", | |
| 36 | + "django.contrib.contenttypes", | |
| 37 | + "django.contrib.sessions", | |
| 38 | + "django.contrib.messages", | |
| 39 | + "django.contrib.staticfiles", | |
| 40 | + "rest_framework", | |
| 41 | + "example_app", | |
| 42 | + "plusrequest", | |
| 43 | +] | |
| 44 | + | |
| 45 | +MIDDLEWARE = [ | |
| 46 | + "django.middleware.security.SecurityMiddleware", | |
| 47 | + "django.contrib.sessions.middleware.SessionMiddleware", | |
| 48 | + "django.middleware.common.CommonMiddleware", | |
| 49 | + "django.middleware.csrf.CsrfViewMiddleware", | |
| 50 | + "django.contrib.auth.middleware.AuthenticationMiddleware", | |
| 51 | + "django.contrib.messages.middleware.MessageMiddleware", | |
| 52 | + "django.middleware.clickjacking.XFrameOptionsMiddleware", | |
| 53 | + "plusrequest.middleware.PlusRequestMiddleware", | |
| 54 | +] | |
| 55 | + | |
| 56 | +ROOT_URLCONF = "example_project.urls" | |
| 57 | + | |
| 58 | +TEMPLATES = [ | |
| 59 | + { | |
| 60 | + "BACKEND": "django.template.backends.django.DjangoTemplates", | |
| 61 | + "DIRS": [], | |
| 62 | + "APP_DIRS": True, | |
| 63 | + "OPTIONS": { | |
| 64 | + "context_processors": [ | |
| 65 | + "django.template.context_processors.request", | |
| 66 | + "django.contrib.auth.context_processors.auth", | |
| 67 | + "django.contrib.messages.context_processors.messages", | |
| 68 | + ], | |
| 69 | + }, | |
| 70 | + }, | |
| 71 | +] | |
| 72 | + | |
| 73 | +WSGI_APPLICATION = "example_project.wsgi.application" | |
| 74 | + | |
| 75 | + | |
| 76 | +# Database | |
| 77 | +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases | |
| 78 | + | |
| 79 | +DATABASES = { | |
| 80 | + "default": { | |
| 81 | + "ENGINE": "django.db.backends.sqlite3", | |
| 82 | + "NAME": BASE_DIR / "db.sqlite3", | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | + | |
| 87 | +# Password validation | |
| 88 | +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators | |
| 89 | + | |
| 90 | +AUTH_PASSWORD_VALIDATORS = [ | |
| 91 | + { | |
| 92 | + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", | |
| 93 | + }, | |
| 94 | + { | |
| 95 | + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", | |
| 99 | + }, | |
| 100 | + { | |
| 101 | + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", | |
| 102 | + }, | |
| 103 | +] | |
| 104 | + | |
| 105 | + | |
| 106 | +# Internationalization | |
| 107 | +# https://docs.djangoproject.com/en/5.2/topics/i18n/ | |
| 108 | + | |
| 109 | +LANGUAGE_CODE = "en-us" | |
| 110 | + | |
| 111 | +TIME_ZONE = "UTC" | |
| 112 | + | |
| 113 | +USE_I18N = True | |
| 114 | + | |
| 115 | +USE_TZ = True | |
| 116 | + | |
| 117 | + | |
| 118 | +# Static files (CSS, JavaScript, Images) | |
| 119 | +# https://docs.djangoproject.com/en/5.2/howto/static-files/ | |
| 120 | + | |
| 121 | +STATIC_URL = "static/" | |
| 122 | + | |
| 123 | +# Default primary key field type | |
| 124 | +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field | |
| 125 | + | |
| 126 | +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" |
+22-0example_project/urls.py
| @@ -0,0 +1,22 @@ | ||
| 1 | +""" | |
| 2 | +URL configuration for example project. | |
| 3 | + | |
| 4 | +The `urlpatterns` list routes URLs to views. For more information please see: | |
| 5 | + https://docs.djangoproject.com/en/5.2/topics/http/urls/ | |
| 6 | +Examples: | |
| 7 | +Function views | |
| 8 | + 1. Add an import: from my_app import views | |
| 9 | + 2. Add a URL to urlpatterns: path('', views.home, name='home') | |
| 10 | +Class-based views | |
| 11 | + 1. Add an import: from other_app.views import Home | |
| 12 | + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') | |
| 13 | +Including another URLconf | |
| 14 | + 1. Import the include() function: from django.urls import include, path | |
| 15 | + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) | |
| 16 | +""" | |
| 17 | + | |
| 18 | +from django.contrib import admin | |
| 19 | +from django.urls import include, path | |
| 20 | + | |
| 21 | + | |
| 22 | +urlpatterns = [path("admin/", admin.site.urls), path("", include("example_app.urls"))] |
+16-0example_project/wsgi.py
| @@ -0,0 +1,16 @@ | ||
| 1 | +""" | |
| 2 | +WSGI config for example project. | |
| 3 | + | |
| 4 | +It exposes the WSGI callable as a module-level variable named ``application``. | |
| 5 | + | |
| 6 | +For more information on this file, see | |
| 7 | +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ | |
| 8 | +""" | |
| 9 | + | |
| 10 | +import os | |
| 11 | + | |
| 12 | +from django.core.wsgi import get_wsgi_application | |
| 13 | + | |
| 14 | +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") | |
| 15 | + | |
| 16 | +application = get_wsgi_application() |
+23-0manage.py
| @@ -0,0 +1,23 @@ | ||
| 1 | +#!/usr/bin/env python | |
| 2 | +"""Django's command-line utility for administrative tasks.""" | |
| 3 | + | |
| 4 | +import os | |
| 5 | +import sys | |
| 6 | + | |
| 7 | + | |
| 8 | +def main(): | |
| 9 | + """Run administrative tasks.""" | |
| 10 | + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") | |
| 11 | + try: | |
| 12 | + from django.core.management import execute_from_command_line | |
| 13 | + except ImportError as exc: | |
| 14 | + raise ImportError( | |
| 15 | + "Couldn't import Django. Are you sure it's installed and " | |
| 16 | + "available on your PYTHONPATH environment variable? Did you " | |
| 17 | + "forget to activate a virtual environment?" | |
| 18 | + ) from exc | |
| 19 | + execute_from_command_line(sys.argv) | |
| 20 | + | |
| 21 | + | |
| 22 | +if __name__ == "__main__": | |
| 23 | + main() |
+3-4plusrequest/middleware.py
| @@ -1,5 +1,4 @@ | ||
| 1 | -from rest_framework.request import Request | |
| 2 | -from plusrequest.request import PlusRequest | |
| 1 | +from plusrequest.timestamp_op import TimestampOp | |
| 3 | 2 | |
| 4 | 3 | |
| 5 | 4 | class PlusRequestMiddleware: |
| @@ -7,12 +6,12 @@ class PlusRequestMiddleware: | ||
| 7 | 6 | self.get_response = get_response |
| 8 | 7 | # One-time configuration and initialization. |
| 9 | 8 | |
| 10 | - def __call__(self, request: Request): | |
| 9 | + def __call__(self, request): | |
| 11 | 10 | # Code to be executed for each request before |
| 12 | 11 | # the view (and later middleware) are called. |
| 13 | 12 | |
| 14 | 13 | # Add header device date functionality to request |
| 15 | - request = PlusRequest._promote_drf_to_plus_request(request) | |
| 14 | + request.top_builder = TimestampOp(request) | |
| 16 | 15 | |
| 17 | 16 | # Ensure the member shortcut exists |
| 18 | 17 | response = self.get_response(request) |
+2-6plusrequest/request.py
| @@ -1,13 +1,14 @@ | ||
| 1 | 1 | from typing import ( |
| 2 | 2 | TYPE_CHECKING, |
| 3 | 3 | ) |
| 4 | +from django.http import HttpRequest | |
| 4 | 5 | from rest_framework.request import Request |
| 5 | 6 | |
| 6 | 7 | from plusrequest.meta import Meta |
| 7 | 8 | from plusrequest.timestamp_op import TimestampOp |
| 8 | 9 | |
| 9 | 10 | |
| 10 | -class PlusRequest(Request): | |
| 11 | +class PlusRequest(Request, HttpRequest): | |
| 11 | 12 | if TYPE_CHECKING: |
| 12 | 13 | META: Meta |
| 13 | 14 | |
| @@ -22,8 +23,3 @@ class PlusRequest(Request): | ||
| 22 | 23 | ... |
| 23 | 24 | return Response(data) |
| 24 | 25 | ```""" |
| 25 | - return TimestampOp(self) | |
| 26 | - | |
| 27 | - @classmethod | |
| 28 | - def _promote_drf_to_plus_request(cls, request: Request): | |
| 29 | - return cls(request._request) |
+3-5plusrequest/timestamp_op.py
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import datetime as dt |
| 2 | -from typing import TYPE_CHECKING | |
| 3 | 2 | from django.db import models |
| 3 | +from rest_framework.request import Request | |
| 4 | 4 | |
| 5 | 5 | from plusrequest.exceptions import ( |
| 6 | 6 | InvalidClientDatetimeField, |
| @@ -10,9 +10,6 @@ from plusrequest.exceptions import ( | ||
| 10 | 10 | from plusrequest.settings import conf |
| 11 | 11 | from plusrequest.types import MissingAction |
| 12 | 12 | |
| 13 | -if TYPE_CHECKING: | |
| 14 | - from plusrequest.request import PlusRequest | |
| 15 | - | |
| 16 | 13 | |
| 17 | 14 | class TimestampOp: |
| 18 | 15 | """Builds a set of instructions on how to interpret client and server timestamps. |
| @@ -21,7 +18,7 @@ class TimestampOp: | ||
| 21 | 18 | |
| 22 | 19 | _missing_action: MissingAction |
| 23 | 20 | |
| 24 | - def __init__(self, parent: "PlusRequest"): | |
| 21 | + def __init__(self, parent: Request): | |
| 25 | 22 | self.parent = parent |
| 26 | 23 | |
| 27 | 24 | def __call__( |
| @@ -72,6 +69,7 @@ class TimestampOp: | ||
| 72 | 69 | raise InvalidClientDatetimeField( |
| 73 | 70 | self._body_field, body_str, self._datetime_format, 400 |
| 74 | 71 | ) |
| 72 | + return self | |
| 75 | 73 | |
| 76 | 74 | def raise_get( |
| 77 | 75 | self, code: str | int | None = None, missing_action: MissingAction | None = None |
+4-2pyproject.toml
| @@ -9,8 +9,10 @@ dependencies = ["django"] | ||
| 9 | 9 | [project.urls] |
| 10 | 10 | Homepage = "https://github.com/huncholane/django-smartwatch" |
| 11 | 11 | |
| 12 | -[tool.setuptools] | |
| 13 | -packages = ["plusrequest"] | |
| 12 | +[tool.setuptools.packages.find] | |
| 13 | +include = ["plusrequest"] | |
| 14 | +where = ["."] | |
| 15 | + | |
| 14 | 16 | [build-system] |
| 15 | 17 | requires = ["setuptools"] |
| 16 | 18 | build-backend = "setuptools.build_meta" |