Skip to content

Commit 68e1723

Browse files
Fix date/datetime input behaviour (#690)
1 parent ca9e7ae commit 68e1723

File tree

4 files changed

+83
-7
lines changed

4 files changed

+83
-7
lines changed

admin-js/src/App.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
SimpleShowLayout, Show,
77
AutocompleteInput,
88
BooleanField, BooleanInput,
9-
DateField, DateInput,
9+
DateField, DateInput, DateTimeInput,
1010
NumberField, NumberInput,
1111
ReferenceField, ReferenceInput as _ReferenceInput,
1212
ReferenceManyField,
@@ -33,7 +33,7 @@ const _body = document.querySelector("body");
3333
const STATE = JSON.parse(_body.dataset.state);
3434
// Create a mapping of components, so we can reference them by name later.
3535
const COMPONENTS = {BooleanField, DateField, NumberField, ReferenceField, ReferenceManyField, TextField,
36-
BooleanInput, DateInput, NumberInput, ReferenceInput, TextInput};
36+
BooleanInput, DateInput, DateTimeInput, NumberInput, ReferenceInput, TextInput};
3737
const VALIDATORS = {email, maxLength, maxValue, minLength, minValue, regex, required};
3838

3939
/** Make an authenticated API request and return the response object. */

aiohttp_admin/backends/abc.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
import json
33
import warnings
44
from abc import ABC, abstractmethod
5-
from datetime import datetime
5+
from datetime import date, datetime
66
from enum import Enum
77
from functools import cached_property, partial
8+
from types import MappingProxyType
89
from typing import Any, Literal, Optional, TypedDict, Union
910

1011
from aiohttp import web
@@ -16,10 +17,17 @@
1617

1718
Record = dict[str, object]
1819

20+
INPUT_TYPES = MappingProxyType({
21+
"BooleanInput": bool,
22+
"DateInput": date,
23+
"DateTimeInput": datetime,
24+
"NumberInput": float
25+
})
26+
1927

2028
class Encoder(json.JSONEncoder):
2129
def default(self, o: object) -> Any:
22-
if isinstance(o, datetime):
30+
if isinstance(o, date):
2331
return str(o)
2432
if isinstance(o, Enum):
2533
return o.value
@@ -92,6 +100,10 @@ def __init__(self) -> None:
92100
if "id" in self.fields and self.primary_key != "id":
93101
warnings.warn("A non-PK 'id' column is likely to break the admin.", stacklevel=2)
94102

103+
d = {k: INPUT_TYPES.get(v["type"], str) for k, v in self.inputs.items()}
104+
# For runtime type checking only.
105+
self._record_type = TypedDict("RecordType", d, total=False) # type: ignore[misc]
106+
95107
async def filter_by_permissions(self, request: web.Request, perm_type: str,
96108
record: Record, original: Optional[Record] = None) -> Record:
97109
"""Return a filtered record containing permissible fields only."""
@@ -182,6 +194,11 @@ async def _get_many(self, request: web.Request) -> web.Response:
182194

183195
async def _create(self, request: web.Request) -> web.Response:
184196
query = parse_obj_as(CreateParams, request.query)
197+
# TODO(Pydantic): Dissallow extra arguments
198+
for k in query["data"]:
199+
if k not in self.inputs and k != "id":
200+
raise web.HTTPBadRequest(reason=f"Invalid field '{k}'")
201+
query["data"] = parse_obj_as(self._record_type, query["data"])
185202
await check_permission(request, f"admin.{self.name}.add", context=(request, query["data"]))
186203
for k, v in query["data"].items():
187204
if v is not None:
@@ -196,6 +213,12 @@ async def _create(self, request: web.Request) -> web.Response:
196213
async def _update(self, request: web.Request) -> web.Response:
197214
await check_permission(request, f"admin.{self.name}.edit", context=(request, None))
198215
query = parse_obj_as(UpdateParams, request.query)
216+
# TODO(Pydantic): Dissallow extra arguments
217+
for k in query["data"]:
218+
if k not in self.inputs and k != "id":
219+
raise web.HTTPBadRequest(reason=f"Invalid field '{k}'")
220+
query["data"] = parse_obj_as(self._record_type, query["data"])
221+
query["previousData"] = parse_obj_as(self._record_type, query["previousData"])
199222

200223
if self.primary_key != "id":
201224
query["data"].pop("id", None)
@@ -224,6 +247,11 @@ async def _update(self, request: web.Request) -> web.Response:
224247
async def _update_many(self, request: web.Request) -> web.Response:
225248
await check_permission(request, f"admin.{self.name}.edit", context=(request, None))
226249
query = parse_obj_as(UpdateManyParams, request.query)
250+
# TODO(Pydantic): Dissallow extra arguments
251+
for k in query["data"]:
252+
if k not in self.inputs and k != "id":
253+
raise web.HTTPBadRequest(reason=f"Invalid field '{k}'")
254+
query["data"] = parse_obj_as(self._record_type, query["data"])
227255

228256
# Check original records are allowed by permission filters.
229257
originals = await self.get_many({"ids": query["ids"]})
@@ -243,6 +271,7 @@ async def _update_many(self, request: web.Request) -> web.Response:
243271
async def _delete(self, request: web.Request) -> web.Response:
244272
await check_permission(request, f"admin.{self.name}.delete", context=(request, None))
245273
query = parse_obj_as(DeleteParams, request.query)
274+
query["previousData"] = parse_obj_as(self._record_type, query["previousData"])
246275

247276
original = await self.get_one({"id": query["id"]})
248277
if not await permits(request, f"admin.{self.name}.delete", context=(request, original)):

aiohttp_admin/backends/sqlalchemy.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import operator
44
import sys
5+
from types import MappingProxyType
56
from typing import Any, Callable, Coroutine, Iterator, Type, TypeVar, Union
67

78
import sqlalchemy as sa
@@ -24,15 +25,15 @@
2425

2526
logger = logging.getLogger(__name__)
2627

27-
FIELD_TYPES = {
28+
FIELD_TYPES = MappingProxyType({
2829
sa.Integer: ("NumberField", "NumberInput"),
2930
sa.Text: ("TextField", "TextInput"),
3031
sa.Float: ("NumberField", "NumberInput"),
3132
sa.Date: ("DateField", "DateInput"),
32-
sa.DateTime: ("DateField", "DateInput"),
33+
sa.DateTime: ("DateField", "DateTimeInput"),
3334
sa.Boolean: ("BooleanField", "BooleanInput"),
3435
sa.String: ("TextField", "TextInput")
35-
}
36+
})
3637

3738

3839
def handle_errors(

tests/test_backends_sqlalchemy.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from datetime import date, datetime
23
from typing import Awaitable, Callable, Type, Union
34

45
import pytest
@@ -239,3 +240,48 @@ class TestModel(base): # type: ignore[misc,valid-type]
239240
async with admin_client.put(url, params=p1, headers=h) as resp:
240241
assert resp.status == 200
241242
assert await resp.json() == {"data": {"id": 5, "num": 5, "other": "that"}}
243+
244+
245+
async def test_datetime(
246+
base: DeclarativeBase, aiohttp_client: Callable[[web.Application], Awaitable[TestClient]],
247+
login: _Login
248+
) -> None:
249+
class TestModel(base): # type: ignore[misc,valid-type]
250+
__tablename__ = "test"
251+
id: Mapped[int] = mapped_column(primary_key=True)
252+
date: Mapped[date]
253+
time: Mapped[datetime]
254+
255+
app = web.Application()
256+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
257+
db = async_sessionmaker(engine, expire_on_commit=False)
258+
async with engine.begin() as conn:
259+
await conn.run_sync(base.metadata.create_all)
260+
async with db.begin() as sess:
261+
sess.add(TestModel(date=date(2023, 4, 23), time=datetime(2023, 1, 2, 3, 4)))
262+
263+
schema: aiohttp_admin.Schema = {
264+
"security": {
265+
"check_credentials": check_credentials,
266+
"secure": False
267+
},
268+
"resources": ({"model": SAResource(engine, TestModel)},)
269+
}
270+
app["admin"] = aiohttp_admin.setup(app, schema)
271+
272+
admin_client = await aiohttp_client(app)
273+
assert admin_client.app
274+
h = await login(admin_client)
275+
276+
url = app["admin"].router["test_get_one"].url_for()
277+
async with admin_client.get(url, params={"id": 1}, headers=h) as resp:
278+
assert resp.status == 200
279+
assert await resp.json() == {"data": {"id": 1, "date": "2023-04-23",
280+
"time": "2023-01-02 03:04:00"}}
281+
282+
url = app["admin"].router["test_create"].url_for()
283+
p = {"data": json.dumps({"date": "2024-05-09", "time": "2020-11-12 03:04:05"})}
284+
async with admin_client.post(url, params=p, headers=h) as resp:
285+
assert resp.status == 200
286+
assert await resp.json() == {"data": {"id": 2, "date": "2024-05-09",
287+
"time": "2020-11-12 03:04:05"}}

0 commit comments

Comments
 (0)