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.
@@ -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)
@@ -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}