Skip to content

Commit

Permalink
Fix date/datetime input behaviour (#690)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dreamsorcerer authored Apr 23, 2023
1 parent ca9e7ae commit 68e1723
Show file tree
Hide file tree
Showing 4 changed files with 83 additions and 7 deletions.
4 changes: 2 additions & 2 deletions admin-js/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
SimpleShowLayout, Show,
AutocompleteInput,
BooleanField, BooleanInput,
DateField, DateInput,
DateField, DateInput, DateTimeInput,
NumberField, NumberInput,
ReferenceField, ReferenceInput as _ReferenceInput,
ReferenceManyField,
Expand All @@ -33,7 +33,7 @@ const _body = document.querySelector("body");
const STATE = JSON.parse(_body.dataset.state);
// Create a mapping of components, so we can reference them by name later.
const COMPONENTS = {BooleanField, DateField, NumberField, ReferenceField, ReferenceManyField, TextField,
BooleanInput, DateInput, NumberInput, ReferenceInput, TextInput};
BooleanInput, DateInput, DateTimeInput, NumberInput, ReferenceInput, TextInput};
const VALIDATORS = {email, maxLength, maxValue, minLength, minValue, regex, required};

/** Make an authenticated API request and return the response object. */
Expand Down
33 changes: 31 additions & 2 deletions aiohttp_admin/backends/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
import json
import warnings
from abc import ABC, abstractmethod
from datetime import datetime
from datetime import date, datetime
from enum import Enum
from functools import cached_property, partial
from types import MappingProxyType
from typing import Any, Literal, Optional, TypedDict, Union

from aiohttp import web
Expand All @@ -16,10 +17,17 @@

Record = dict[str, object]

INPUT_TYPES = MappingProxyType({
"BooleanInput": bool,
"DateInput": date,
"DateTimeInput": datetime,
"NumberInput": float
})


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

d = {k: INPUT_TYPES.get(v["type"], str) for k, v in self.inputs.items()}
# For runtime type checking only.
self._record_type = TypedDict("RecordType", d, total=False) # type: ignore[misc]

async def filter_by_permissions(self, request: web.Request, perm_type: str,
record: Record, original: Optional[Record] = None) -> Record:
"""Return a filtered record containing permissible fields only."""
Expand Down Expand Up @@ -182,6 +194,11 @@ async def _get_many(self, request: web.Request) -> web.Response:

async def _create(self, request: web.Request) -> web.Response:
query = parse_obj_as(CreateParams, request.query)
# TODO(Pydantic): Dissallow extra arguments
for k in query["data"]:
if k not in self.inputs and k != "id":
raise web.HTTPBadRequest(reason=f"Invalid field '{k}'")
query["data"] = parse_obj_as(self._record_type, query["data"])
await check_permission(request, f"admin.{self.name}.add", context=(request, query["data"]))
for k, v in query["data"].items():
if v is not None:
Expand All @@ -196,6 +213,12 @@ async def _create(self, request: web.Request) -> web.Response:
async def _update(self, request: web.Request) -> web.Response:
await check_permission(request, f"admin.{self.name}.edit", context=(request, None))
query = parse_obj_as(UpdateParams, request.query)
# TODO(Pydantic): Dissallow extra arguments
for k in query["data"]:
if k not in self.inputs and k != "id":
raise web.HTTPBadRequest(reason=f"Invalid field '{k}'")
query["data"] = parse_obj_as(self._record_type, query["data"])
query["previousData"] = parse_obj_as(self._record_type, query["previousData"])

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

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

original = await self.get_one({"id": query["id"]})
if not await permits(request, f"admin.{self.name}.delete", context=(request, original)):
Expand Down
7 changes: 4 additions & 3 deletions aiohttp_admin/backends/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import operator
import sys
from types import MappingProxyType
from typing import Any, Callable, Coroutine, Iterator, Type, TypeVar, Union

import sqlalchemy as sa
Expand All @@ -24,15 +25,15 @@

logger = logging.getLogger(__name__)

FIELD_TYPES = {
FIELD_TYPES = MappingProxyType({
sa.Integer: ("NumberField", "NumberInput"),
sa.Text: ("TextField", "TextInput"),
sa.Float: ("NumberField", "NumberInput"),
sa.Date: ("DateField", "DateInput"),
sa.DateTime: ("DateField", "DateInput"),
sa.DateTime: ("DateField", "DateTimeInput"),
sa.Boolean: ("BooleanField", "BooleanInput"),
sa.String: ("TextField", "TextInput")
}
})


def handle_errors(
Expand Down
46 changes: 46 additions & 0 deletions tests/test_backends_sqlalchemy.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from datetime import date, datetime
from typing import Awaitable, Callable, Type, Union

import pytest
Expand Down Expand Up @@ -239,3 +240,48 @@ class TestModel(base): # type: ignore[misc,valid-type]
async with admin_client.put(url, params=p1, headers=h) as resp:
assert resp.status == 200
assert await resp.json() == {"data": {"id": 5, "num": 5, "other": "that"}}


async def test_datetime(
base: DeclarativeBase, aiohttp_client: Callable[[web.Application], Awaitable[TestClient]],
login: _Login
) -> None:
class TestModel(base): # type: ignore[misc,valid-type]
__tablename__ = "test"
id: Mapped[int] = mapped_column(primary_key=True)
date: Mapped[date]
time: Mapped[datetime]

app = web.Application()
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
db = async_sessionmaker(engine, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(base.metadata.create_all)
async with db.begin() as sess:
sess.add(TestModel(date=date(2023, 4, 23), time=datetime(2023, 1, 2, 3, 4)))

schema: aiohttp_admin.Schema = {
"security": {
"check_credentials": check_credentials,
"secure": False
},
"resources": ({"model": SAResource(engine, TestModel)},)
}
app["admin"] = aiohttp_admin.setup(app, schema)

admin_client = await aiohttp_client(app)
assert admin_client.app
h = await login(admin_client)

url = app["admin"].router["test_get_one"].url_for()
async with admin_client.get(url, params={"id": 1}, headers=h) as resp:
assert resp.status == 200
assert await resp.json() == {"data": {"id": 1, "date": "2023-04-23",
"time": "2023-01-02 03:04:00"}}

url = app["admin"].router["test_create"].url_for()
p = {"data": json.dumps({"date": "2024-05-09", "time": "2020-11-12 03:04:05"})}
async with admin_client.post(url, params=p, headers=h) as resp:
assert resp.status == 200
assert await resp.json() == {"data": {"id": 2, "date": "2024-05-09",
"time": "2020-11-12 03:04:05"}}

0 comments on commit 68e1723

Please sign in to comment.