fastapi-panel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_panel/__init__.py +77 -0
- fastapi_panel/__main__.py +97 -0
- fastapi_panel/auth.py +189 -0
- fastapi_panel/db.py +41 -0
- fastapi_panel/filters.py +215 -0
- fastapi_panel/forms.py +733 -0
- fastapi_panel/imports.py +264 -0
- fastapi_panel/introspect.py +111 -0
- fastapi_panel/log.py +52 -0
- fastapi_panel/options.py +717 -0
- fastapi_panel/py.typed +0 -0
- fastapi_panel/security.py +139 -0
- fastapi_panel/site.py +602 -0
- fastapi_panel/static/panel.css +420 -0
- fastapi_panel/static/panel.js +404 -0
- fastapi_panel/static/theme.js +5 -0
- fastapi_panel/storage.py +38 -0
- fastapi_panel/templates/fastapi_panel/base.html +76 -0
- fastapi_panel/templates/fastapi_panel/delete.html +25 -0
- fastapi_panel/templates/fastapi_panel/error.html +10 -0
- fastapi_panel/templates/fastapi_panel/form.html +113 -0
- fastapi_panel/templates/fastapi_panel/history.html +23 -0
- fastapi_panel/templates/fastapi_panel/import.html +76 -0
- fastapi_panel/templates/fastapi_panel/index.html +43 -0
- fastapi_panel/templates/fastapi_panel/list.html +131 -0
- fastapi_panel/templates/fastapi_panel/login.html +15 -0
- fastapi_panel/templates/fastapi_panel/password.html +17 -0
- fastapi_panel/templates/fastapi_panel/popup_done.html +4 -0
- fastapi_panel/templates/fastapi_panel/widgets.html +74 -0
- fastapi_panel/views.py +710 -0
- fastapi_panel-0.1.0.dist-info/METADATA +151 -0
- fastapi_panel-0.1.0.dist-info/RECORD +34 -0
- fastapi_panel-0.1.0.dist-info/WHEEL +4 -0
- fastapi_panel-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .auth import AuthBackend, ModelAuth, StaticAuth
|
|
4
|
+
from .filters import Filter, SimpleFilter
|
|
5
|
+
from .forms import (
|
|
6
|
+
BooleanField,
|
|
7
|
+
CharField,
|
|
8
|
+
ChoiceField,
|
|
9
|
+
DateField,
|
|
10
|
+
DateTimeField,
|
|
11
|
+
DecimalField,
|
|
12
|
+
EmailField,
|
|
13
|
+
Field,
|
|
14
|
+
FileField,
|
|
15
|
+
FloatField,
|
|
16
|
+
ImageField,
|
|
17
|
+
IntegerField,
|
|
18
|
+
JSONField,
|
|
19
|
+
TextField,
|
|
20
|
+
TimeField,
|
|
21
|
+
UUIDField,
|
|
22
|
+
ValidationError,
|
|
23
|
+
)
|
|
24
|
+
from .log import AdminLog
|
|
25
|
+
from .options import (
|
|
26
|
+
ImproperlyConfigured,
|
|
27
|
+
InlineAdmin,
|
|
28
|
+
ModelAdmin,
|
|
29
|
+
StackedInline,
|
|
30
|
+
TabularInline,
|
|
31
|
+
action,
|
|
32
|
+
display,
|
|
33
|
+
)
|
|
34
|
+
from .security import hash_password, verify_password
|
|
35
|
+
from .site import AdminSite, autodiscover, register, site
|
|
36
|
+
from .storage import LocalStorage, Storage
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AdminLog",
|
|
40
|
+
"AdminSite",
|
|
41
|
+
"AuthBackend",
|
|
42
|
+
"BooleanField",
|
|
43
|
+
"CharField",
|
|
44
|
+
"ChoiceField",
|
|
45
|
+
"DateField",
|
|
46
|
+
"DateTimeField",
|
|
47
|
+
"DecimalField",
|
|
48
|
+
"EmailField",
|
|
49
|
+
"Field",
|
|
50
|
+
"FileField",
|
|
51
|
+
"Filter",
|
|
52
|
+
"FloatField",
|
|
53
|
+
"ImageField",
|
|
54
|
+
"ImproperlyConfigured",
|
|
55
|
+
"InlineAdmin",
|
|
56
|
+
"IntegerField",
|
|
57
|
+
"JSONField",
|
|
58
|
+
"LocalStorage",
|
|
59
|
+
"ModelAdmin",
|
|
60
|
+
"ModelAuth",
|
|
61
|
+
"SimpleFilter",
|
|
62
|
+
"StackedInline",
|
|
63
|
+
"StaticAuth",
|
|
64
|
+
"Storage",
|
|
65
|
+
"TabularInline",
|
|
66
|
+
"TextField",
|
|
67
|
+
"TimeField",
|
|
68
|
+
"UUIDField",
|
|
69
|
+
"ValidationError",
|
|
70
|
+
"action",
|
|
71
|
+
"autodiscover",
|
|
72
|
+
"display",
|
|
73
|
+
"hash_password",
|
|
74
|
+
"register",
|
|
75
|
+
"site",
|
|
76
|
+
"verify_password",
|
|
77
|
+
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import getpass
|
|
4
|
+
import importlib
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.exc import DBAPIError
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
11
|
+
|
|
12
|
+
from .auth import ModelAuth
|
|
13
|
+
from .introspect import coerce_to_column
|
|
14
|
+
from .security import hash_password
|
|
15
|
+
from .site import MIN_PASSWORD_LENGTH, AdminSite, site
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_password() -> str:
|
|
19
|
+
password = getpass.getpass("Password: ")
|
|
20
|
+
if password != getpass.getpass("Again: "):
|
|
21
|
+
raise SystemExit("The passwords don't match.")
|
|
22
|
+
return password
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_site(target: str) -> AdminSite:
|
|
26
|
+
"""`main` or `main:panel`. Importing runs your `site.mount(...)`, which is where the engine comes from."""
|
|
27
|
+
sys.path.insert(0, os.getcwd())
|
|
28
|
+
module_name, _, attr = target.partition(":")
|
|
29
|
+
try:
|
|
30
|
+
module = importlib.import_module(module_name)
|
|
31
|
+
except ImportError as exc:
|
|
32
|
+
raise SystemExit(f"Couldn't import {module_name!r}: {exc}") from None
|
|
33
|
+
if attr:
|
|
34
|
+
found = getattr(module, attr, None)
|
|
35
|
+
if not isinstance(found, AdminSite):
|
|
36
|
+
raise SystemExit(f"{target} is not an AdminSite.")
|
|
37
|
+
return found
|
|
38
|
+
return next((value for value in vars(module).values() if isinstance(value, AdminSite)), site)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def save_user(panel: AdminSite, *args: Any, **kwargs: Any) -> Any:
|
|
42
|
+
try:
|
|
43
|
+
return await panel.db.run(lambda session: panel.auth.create_user(session, *args, **kwargs))
|
|
44
|
+
finally:
|
|
45
|
+
# An open async connection would keep the process from exiting.
|
|
46
|
+
bind = panel.db.maker.kw.get("bind")
|
|
47
|
+
if isinstance(bind, AsyncEngine):
|
|
48
|
+
await bind.dispose()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_user(args: argparse.Namespace) -> int:
|
|
52
|
+
panel = find_site(args.target)
|
|
53
|
+
if not hasattr(panel, "db"):
|
|
54
|
+
raise SystemExit(f"{args.target} doesn't call site.mount(), so there's no database to use.")
|
|
55
|
+
auth = panel.auth
|
|
56
|
+
if not isinstance(auth, ModelAuth):
|
|
57
|
+
raise SystemExit("createuser only works with ModelAuth. Other backends keep their users elsewhere.")
|
|
58
|
+
|
|
59
|
+
username = args.username or input("Username: ").strip()
|
|
60
|
+
if not username:
|
|
61
|
+
raise SystemExit("A username is required.")
|
|
62
|
+
extra = {}
|
|
63
|
+
for prop in auth.required_fields():
|
|
64
|
+
try:
|
|
65
|
+
extra[prop.key] = coerce_to_column(prop.columns[0], input(f"{prop.key}: ").strip())
|
|
66
|
+
except ValueError:
|
|
67
|
+
raise SystemExit(f"That's not a valid value for {prop.key}.") from None
|
|
68
|
+
password = os.environ.get("FASTAPI_PANEL_PASSWORD") or read_password()
|
|
69
|
+
if len(password) < MIN_PASSWORD_LENGTH:
|
|
70
|
+
raise SystemExit(f"Use at least {MIN_PASSWORD_LENGTH} characters.")
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
asyncio.run(save_user(panel, username, password, superuser=not args.no_superuser, **extra))
|
|
74
|
+
except DBAPIError as exc:
|
|
75
|
+
raise SystemExit(f"Couldn't create the user: {exc.orig}. Do the tables exist, and is the name free?") from None
|
|
76
|
+
print(f"Created {username}.")
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main() -> int:
|
|
81
|
+
parser = argparse.ArgumentParser(prog="python -m fastapi_panel")
|
|
82
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
83
|
+
commands.add_parser("hash-password", help="hash a password for StaticAuth or your own user table")
|
|
84
|
+
creating = commands.add_parser("createuser", help="add a user to the table behind ModelAuth")
|
|
85
|
+
creating.add_argument("target", help="the module that mounts the panel, like main or app.main:panel")
|
|
86
|
+
creating.add_argument("--username")
|
|
87
|
+
creating.add_argument("--no-superuser", action="store_true", help="create a regular staff user")
|
|
88
|
+
args = parser.parse_args()
|
|
89
|
+
|
|
90
|
+
if args.command == "createuser":
|
|
91
|
+
return create_user(args)
|
|
92
|
+
print(hash_password(read_password()))
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
raise SystemExit(main())
|
fastapi_panel/auth.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import inspect, select
|
|
9
|
+
|
|
10
|
+
from .introspect import object_label, pk_criterion, pk_string
|
|
11
|
+
from .security import DUMMY_HASH, hash_password, verify_password
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthBackend:
|
|
15
|
+
"""Tells the panel who may log in and what they may do.
|
|
16
|
+
|
|
17
|
+
Methods taking `session` run with an open database session. `check_password` runs
|
|
18
|
+
in a worker thread, so slow hashers are safe.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
can_change_password = False
|
|
22
|
+
|
|
23
|
+
def find_user(self, session: Any, username: str) -> Any | None:
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
def get_user(self, session: Any, user_id: str) -> Any | None:
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
|
|
29
|
+
def check_password(self, user: Any | None, password: str) -> bool:
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
def user_id(self, user: Any) -> str:
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
def user_label(self, user: Any) -> str:
|
|
36
|
+
return object_label(user)
|
|
37
|
+
|
|
38
|
+
def is_allowed(self, user: Any) -> bool:
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
def has_perm(self, user: Any, perm: str) -> bool:
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
def session_hash(self, user: Any) -> str:
|
|
45
|
+
"""Sessions die when this value changes. Derive it from the password hash."""
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
def set_password(self, session: Any, user: Any, password: str) -> None:
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ModelAuth(AuthBackend):
|
|
53
|
+
"""Log in against one of your own models.
|
|
54
|
+
|
|
55
|
+
`active_field` and `staff_field` must exist or be passed as None on purpose: without
|
|
56
|
+
this, every customer in a shared `users` table could log in to the panel.
|
|
57
|
+
Bring your own hasher through `verify` and `hasher` (bcrypt, argon2, passlib...).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
can_change_password = True
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
model: type,
|
|
65
|
+
*,
|
|
66
|
+
username_field: str = "username",
|
|
67
|
+
password_field: str = "password",
|
|
68
|
+
active_field: str | None = "is_active",
|
|
69
|
+
staff_field: str | None = "is_staff",
|
|
70
|
+
superuser_field: str | None = None,
|
|
71
|
+
permissions_field: str | None = None,
|
|
72
|
+
verify: Callable[[str, str], bool] = verify_password,
|
|
73
|
+
hasher: Callable[[str], str] = hash_password,
|
|
74
|
+
) -> None:
|
|
75
|
+
columns = inspect(model).attrs
|
|
76
|
+
for name in (username_field, password_field, active_field, staff_field, superuser_field, permissions_field):
|
|
77
|
+
if name is not None and name not in columns:
|
|
78
|
+
raise ValueError(f"{model.__name__} has no field {name!r}. Pass the right name, or None to skip it.")
|
|
79
|
+
self.model = model
|
|
80
|
+
self.username_field = username_field
|
|
81
|
+
self.password_field = password_field
|
|
82
|
+
self.active_field = active_field
|
|
83
|
+
self.staff_field = staff_field
|
|
84
|
+
self.superuser_field = superuser_field
|
|
85
|
+
self.permissions_field = permissions_field
|
|
86
|
+
self.verify = verify
|
|
87
|
+
self.hasher = hasher
|
|
88
|
+
|
|
89
|
+
def find_user(self, session: Any, username: str) -> Any | None:
|
|
90
|
+
column = getattr(self.model, self.username_field)
|
|
91
|
+
return session.scalars(select(self.model).where(column == username)).first()
|
|
92
|
+
|
|
93
|
+
def get_user(self, session: Any, user_id: str) -> Any | None:
|
|
94
|
+
try:
|
|
95
|
+
return session.scalars(select(self.model).where(pk_criterion(self.model, user_id))).first()
|
|
96
|
+
except ValueError:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def check_password(self, user: Any | None, password: str) -> bool:
|
|
100
|
+
if user is None:
|
|
101
|
+
verify_password(password, DUMMY_HASH)
|
|
102
|
+
return False
|
|
103
|
+
stored = getattr(user, self.password_field)
|
|
104
|
+
return bool(stored) and self.verify(password, stored)
|
|
105
|
+
|
|
106
|
+
def user_id(self, user: Any) -> str:
|
|
107
|
+
return pk_string(user)
|
|
108
|
+
|
|
109
|
+
def user_label(self, user: Any) -> str:
|
|
110
|
+
return str(getattr(user, self.username_field))
|
|
111
|
+
|
|
112
|
+
def is_allowed(self, user: Any) -> bool:
|
|
113
|
+
return all(getattr(user, name) for name in (self.active_field, self.staff_field) if name)
|
|
114
|
+
|
|
115
|
+
def has_perm(self, user: Any, perm: str) -> bool:
|
|
116
|
+
if self.superuser_field and getattr(user, self.superuser_field):
|
|
117
|
+
return True
|
|
118
|
+
if not self.permissions_field:
|
|
119
|
+
return self.superuser_field is None
|
|
120
|
+
granted = getattr(user, self.permissions_field) or []
|
|
121
|
+
return perm in granted or "*" in granted or f"{perm.split('.')[0]}.*" in granted
|
|
122
|
+
|
|
123
|
+
def session_hash(self, user: Any) -> str:
|
|
124
|
+
return hashlib.sha256(str(getattr(user, self.password_field)).encode()).hexdigest()
|
|
125
|
+
|
|
126
|
+
def set_password(self, session: Any, user: Any, password: str) -> None:
|
|
127
|
+
setattr(user, self.password_field, self.hasher(password))
|
|
128
|
+
|
|
129
|
+
def create_user(self, session: Any, username: str, password: str, *, superuser: bool, **extra: Any) -> Any:
|
|
130
|
+
user = self.model(**extra)
|
|
131
|
+
setattr(user, self.username_field, username)
|
|
132
|
+
self.set_password(session, user, password)
|
|
133
|
+
for name in (self.active_field, self.staff_field):
|
|
134
|
+
if name:
|
|
135
|
+
setattr(user, name, True)
|
|
136
|
+
if self.superuser_field:
|
|
137
|
+
setattr(user, self.superuser_field, superuser)
|
|
138
|
+
session.add(user)
|
|
139
|
+
session.commit()
|
|
140
|
+
return user
|
|
141
|
+
|
|
142
|
+
def required_fields(self) -> list[Any]:
|
|
143
|
+
"""Other columns a new user can't be saved without, like an email."""
|
|
144
|
+
known = {self.username_field, self.password_field, self.active_field, self.staff_field, self.superuser_field}
|
|
145
|
+
return [
|
|
146
|
+
prop
|
|
147
|
+
for prop in inspect(self.model).column_attrs
|
|
148
|
+
if prop.key not in known
|
|
149
|
+
and not prop.columns[0].nullable
|
|
150
|
+
and not prop.columns[0].primary_key
|
|
151
|
+
and prop.columns[0].default is None
|
|
152
|
+
and prop.columns[0].server_default is None
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class StaticUser:
|
|
158
|
+
username: str
|
|
159
|
+
password_hash: str
|
|
160
|
+
|
|
161
|
+
def __str__(self) -> str:
|
|
162
|
+
return self.username
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class StaticAuth(AuthBackend):
|
|
166
|
+
"""Users kept in config, no table needed. Hashes come from `python -m fastapi_panel hash-password`."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, users: Mapping[str, str]) -> None:
|
|
169
|
+
for username, encoded in users.items():
|
|
170
|
+
if not encoded.startswith("scrypt$"):
|
|
171
|
+
raise ValueError(f"Password for {username!r} must be a hash, not plain text.")
|
|
172
|
+
self.users = {name: StaticUser(name, encoded) for name, encoded in users.items()}
|
|
173
|
+
|
|
174
|
+
def find_user(self, session: Any, username: str) -> StaticUser | None:
|
|
175
|
+
return self.users.get(username)
|
|
176
|
+
|
|
177
|
+
get_user = find_user
|
|
178
|
+
|
|
179
|
+
def check_password(self, user: StaticUser | None, password: str) -> bool:
|
|
180
|
+
if user is None:
|
|
181
|
+
verify_password(password, DUMMY_HASH)
|
|
182
|
+
return False
|
|
183
|
+
return verify_password(password, user.password_hash)
|
|
184
|
+
|
|
185
|
+
def user_id(self, user: StaticUser) -> str:
|
|
186
|
+
return user.username
|
|
187
|
+
|
|
188
|
+
def session_hash(self, user: StaticUser) -> str:
|
|
189
|
+
return hashlib.sha256(user.password_hash.encode()).hexdigest()
|
fastapi_panel/db.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Engine
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
8
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
9
|
+
from starlette.concurrency import run_in_threadpool
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Database:
|
|
15
|
+
"""Runs plain synchronous code against a sync or async engine.
|
|
16
|
+
|
|
17
|
+
Everything in the panel is written once, against a regular Session. Async engines
|
|
18
|
+
go through `run_sync`, which is why lazy loading just works in templates and hooks.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, bind: Any) -> None:
|
|
22
|
+
if isinstance(bind, AsyncEngine):
|
|
23
|
+
bind = async_sessionmaker(bind, expire_on_commit=False)
|
|
24
|
+
elif isinstance(bind, Engine):
|
|
25
|
+
bind = sessionmaker(bind, expire_on_commit=False)
|
|
26
|
+
if not isinstance(bind, (sessionmaker, async_sessionmaker)):
|
|
27
|
+
raise TypeError("engine must be an Engine, AsyncEngine or a sessionmaker")
|
|
28
|
+
self.maker = bind
|
|
29
|
+
self.is_async = isinstance(bind, async_sessionmaker)
|
|
30
|
+
|
|
31
|
+
async def run(self, fn: Callable[..., T], *args: Any) -> T:
|
|
32
|
+
if self.is_async:
|
|
33
|
+
session: AsyncSession
|
|
34
|
+
async with self.maker() as session:
|
|
35
|
+
return await session.run_sync(fn, *args)
|
|
36
|
+
return await run_in_threadpool(self._run_sync, fn, *args)
|
|
37
|
+
|
|
38
|
+
def _run_sync(self, fn: Callable[..., T], *args: Any) -> T:
|
|
39
|
+
session: Session
|
|
40
|
+
with self.maker() as session:
|
|
41
|
+
return fn(session, *args)
|
fastapi_panel/filters.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import sqlalchemy as sa
|
|
7
|
+
from sqlalchemy import extract, inspect
|
|
8
|
+
|
|
9
|
+
from .introspect import coerce_to_column, humanize, object_label
|
|
10
|
+
|
|
11
|
+
MAX_RELATION_OPTIONS = 50
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Filter:
|
|
15
|
+
"""A box in the changelist sidebar. Only registered filters ever read the query string."""
|
|
16
|
+
|
|
17
|
+
title = ""
|
|
18
|
+
parameter = ""
|
|
19
|
+
|
|
20
|
+
def options(self, session: Any, request: Any) -> list[tuple[str, str]]:
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
|
|
23
|
+
def apply(self, stmt: Any, value: str, request: Any) -> Any:
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SimpleFilter(Filter):
|
|
28
|
+
"""Subclass this for custom filters: set `title` and `parameter`, define `lookups` and `queryset`."""
|
|
29
|
+
|
|
30
|
+
def lookups(self, request: Any) -> list[tuple[str, str]]:
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
def queryset(self, request: Any, stmt: Any, value: str) -> Any:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
def options(self, session: Any, request: Any) -> list[tuple[str, str]]:
|
|
37
|
+
return [(str(v), label) for v, label in self.lookups(request)]
|
|
38
|
+
|
|
39
|
+
def apply(self, stmt: Any, value: str, request: Any) -> Any:
|
|
40
|
+
if value not in {v for v, _ in self.options(None, request)}:
|
|
41
|
+
return stmt
|
|
42
|
+
return self.queryset(request, stmt, value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _ColumnFilter(Filter):
|
|
46
|
+
def __init__(self, model: type, name: str) -> None:
|
|
47
|
+
self.model = model
|
|
48
|
+
self.name = name
|
|
49
|
+
self.parameter = name
|
|
50
|
+
self.title = humanize(name)
|
|
51
|
+
self.attr = getattr(model, name)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BooleanFilter(_ColumnFilter):
|
|
55
|
+
def options(self, session: Any, request: Any) -> list[tuple[str, str]]:
|
|
56
|
+
return [("1", "Yes"), ("0", "No")]
|
|
57
|
+
|
|
58
|
+
def apply(self, stmt: Any, value: str, request: Any) -> Any:
|
|
59
|
+
if value not in ("0", "1"):
|
|
60
|
+
return stmt
|
|
61
|
+
return stmt.where(self.attr.is_(value == "1"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class EnumFilter(_ColumnFilter):
|
|
65
|
+
def __init__(self, model: type, name: str) -> None:
|
|
66
|
+
super().__init__(model, name)
|
|
67
|
+
type_ = self.attr.type
|
|
68
|
+
if type_.enum_class:
|
|
69
|
+
self._values = {m.name: m for m in type_.enum_class}
|
|
70
|
+
else:
|
|
71
|
+
self._values = {e: e for e in type_.enums}
|
|
72
|
+
|
|
73
|
+
def options(self, session: Any, request: Any) -> list[tuple[str, str]]:
|
|
74
|
+
return [(key, humanize(key.lower())) for key in self._values]
|
|
75
|
+
|
|
76
|
+
def apply(self, stmt: Any, value: str, request: Any) -> Any:
|
|
77
|
+
if value not in self._values:
|
|
78
|
+
return stmt
|
|
79
|
+
return stmt.where(self.attr == self._values[value])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ValuesFilter(_ColumnFilter):
|
|
83
|
+
"""Distinct values of a plain column, for low-cardinality text like a country code."""
|
|
84
|
+
|
|
85
|
+
def options(self, session: Any, request: Any) -> list[tuple[str, str]]:
|
|
86
|
+
stmt = sa.select(self.attr).where(self.attr.is_not(None)).distinct().order_by(self.attr)
|
|
87
|
+
return [(str(v), str(v)) for v in session.scalars(stmt.limit(MAX_RELATION_OPTIONS))]
|
|
88
|
+
|
|
89
|
+
def apply(self, stmt: Any, value: str, request: Any) -> Any:
|
|
90
|
+
try:
|
|
91
|
+
return stmt.where(self.attr == coerce_to_column(self.attr, value))
|
|
92
|
+
except ValueError:
|
|
93
|
+
return stmt
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DateFilter(_ColumnFilter):
|
|
97
|
+
RANGES = {"today": 0, "7d": 7, "30d": 30, "365d": 365}
|
|
98
|
+
|
|
99
|
+
def __init__(self, model: type, name: str, site: Any) -> None:
|
|
100
|
+
super().__init__(model, name)
|
|
101
|
+
self.site = site
|
|
102
|
+
|
|
103
|
+
def options(self, session: Any, request: Any) -> list[tuple[str, str]]:
|
|
104
|
+
return [("today", "Today"), ("7d", "Past 7 days"), ("30d", "Past 30 days"), ("365d", "Past year")]
|
|
105
|
+
|
|
106
|
+
def apply(self, stmt: Any, value: str, request: Any) -> Any:
|
|
107
|
+
if value not in self.RANGES:
|
|
108
|
+
return stmt
|
|
109
|
+
today = self.site.now().replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
|
|
110
|
+
start = today - dt.timedelta(days=self.RANGES[value])
|
|
111
|
+
if not isinstance(self.attr.type, sa.DateTime):
|
|
112
|
+
return stmt.where(self.attr >= start.date())
|
|
113
|
+
return stmt.where(self.attr >= self.site.from_local(start, bool(self.attr.type.timezone)))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RelationFilter(_ColumnFilter):
|
|
117
|
+
def __init__(self, model: type, name: str) -> None:
|
|
118
|
+
super().__init__(model, name)
|
|
119
|
+
self.target = self.attr.property.mapper.class_
|
|
120
|
+
self.target_pk = inspect(self.target).primary_key[0]
|
|
121
|
+
|
|
122
|
+
def options(self, session: Any, request: Any) -> list[tuple[str, str]]:
|
|
123
|
+
# shortcut: options stop at MAX_RELATION_OPTIONS, a SimpleFilter fits bigger tables better
|
|
124
|
+
key = inspect(self.target).get_property_by_column(self.target_pk).key
|
|
125
|
+
stmt = sa.select(self.target).limit(MAX_RELATION_OPTIONS)
|
|
126
|
+
return [(str(getattr(o, key)), object_label(o)) for o in session.scalars(stmt)]
|
|
127
|
+
|
|
128
|
+
def apply(self, stmt: Any, value: str, request: Any) -> Any:
|
|
129
|
+
try:
|
|
130
|
+
pk = coerce_to_column(self.target_pk, value)
|
|
131
|
+
except ValueError:
|
|
132
|
+
return stmt
|
|
133
|
+
match = self.target_pk == pk
|
|
134
|
+
return stmt.where(self.attr.any(match) if self.attr.property.uselist else self.attr.has(match))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def make_filter(model: type, spec: Any, site: Any) -> Filter:
|
|
138
|
+
if isinstance(spec, type) and issubclass(spec, Filter):
|
|
139
|
+
return spec()
|
|
140
|
+
if isinstance(spec, Filter):
|
|
141
|
+
return spec
|
|
142
|
+
mapper = inspect(model)
|
|
143
|
+
if spec in mapper.relationships:
|
|
144
|
+
return RelationFilter(model, spec)
|
|
145
|
+
if spec not in mapper.column_attrs:
|
|
146
|
+
raise AttributeError(f"{model.__name__} has no column or relationship called {spec!r}")
|
|
147
|
+
type_ = getattr(getattr(model, spec).type, "impl_instance", getattr(model, spec).type)
|
|
148
|
+
if isinstance(type_, sa.Boolean):
|
|
149
|
+
return BooleanFilter(model, spec)
|
|
150
|
+
if isinstance(type_, sa.Enum):
|
|
151
|
+
return EnumFilter(model, spec)
|
|
152
|
+
if isinstance(type_, (sa.DateTime, sa.Date)):
|
|
153
|
+
return DateFilter(model, spec, site)
|
|
154
|
+
return ValuesFilter(model, spec)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class DateHierarchy:
|
|
158
|
+
"""Year > month > day drill-down above the changelist. Filters on ranges so indexes still apply.
|
|
159
|
+
|
|
160
|
+
shortcut: it groups by stored time, not the site timezone, because shifting needs SQL per database.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, model: type, name: str) -> None:
|
|
164
|
+
self.name = name
|
|
165
|
+
self.attr = getattr(model, name)
|
|
166
|
+
|
|
167
|
+
def _selection(self, params: Any) -> dict[str, int]:
|
|
168
|
+
selected: dict[str, int] = {}
|
|
169
|
+
for part in ("year", "month", "day"):
|
|
170
|
+
raw = params.get(f"{self.name}__{part}", "")
|
|
171
|
+
if not raw.isdigit():
|
|
172
|
+
break
|
|
173
|
+
selected[part] = int(raw)
|
|
174
|
+
return selected
|
|
175
|
+
|
|
176
|
+
def apply(self, stmt: Any, params: Any) -> Any:
|
|
177
|
+
selected = self._selection(params)
|
|
178
|
+
if not selected:
|
|
179
|
+
return stmt
|
|
180
|
+
try:
|
|
181
|
+
year = selected["year"]
|
|
182
|
+
month, day = selected.get("month", 1), selected.get("day", 1)
|
|
183
|
+
start = dt.datetime(year, month, day)
|
|
184
|
+
if "day" in selected:
|
|
185
|
+
end = start + dt.timedelta(days=1)
|
|
186
|
+
elif "month" in selected:
|
|
187
|
+
end = dt.datetime(year + month // 12, month % 12 + 1, 1)
|
|
188
|
+
else:
|
|
189
|
+
end = dt.datetime(year + 1, 1, 1)
|
|
190
|
+
except (ValueError, OverflowError):
|
|
191
|
+
return stmt
|
|
192
|
+
if getattr(self.attr.type, "timezone", False):
|
|
193
|
+
start, end = start.replace(tzinfo=dt.timezone.utc), end.replace(tzinfo=dt.timezone.utc)
|
|
194
|
+
return stmt.where(self.attr >= start, self.attr < end)
|
|
195
|
+
|
|
196
|
+
def links(self, session: Any, stmt: Any, params: Any, url: Any) -> dict[str, Any]:
|
|
197
|
+
selected = self._selection(params)
|
|
198
|
+
parts = ["year", "month", "day"]
|
|
199
|
+
current = parts[min(len(selected), 2)]
|
|
200
|
+
keep: dict[str, Any] = {}
|
|
201
|
+
crumbs = []
|
|
202
|
+
for part, value in selected.items():
|
|
203
|
+
keep[f"{self.name}__{part}"] = value
|
|
204
|
+
crumbs.append({"label": str(value), "url": url(**keep)})
|
|
205
|
+
if len(selected) == 3:
|
|
206
|
+
return {"crumbs": crumbs, "links": []}
|
|
207
|
+
column = extract(current, self.attr)
|
|
208
|
+
values = session.scalars(
|
|
209
|
+
stmt.with_only_columns(column).where(self.attr.is_not(None)).distinct().order_by(None).limit(40)
|
|
210
|
+
)
|
|
211
|
+
links = [
|
|
212
|
+
{"label": str(int(v)), "url": url(**keep, **{f"{self.name}__{current}": int(v)})} for v in sorted(values)
|
|
213
|
+
]
|
|
214
|
+
clear = url(**{f"{self.name}__{p}": None for p in parts})
|
|
215
|
+
return {"crumbs": crumbs, "links": links, "clear": clear}
|