jongo 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.
- jongo/__init__.py +33 -0
- jongo/__main__.py +3 -0
- jongo/admin.py +486 -0
- jongo/app.py +492 -0
- jongo/auth.py +130 -0
- jongo/cli.py +227 -0
- jongo/compiler/__init__.py +5 -0
- jongo/compiler/bundle.py +213 -0
- jongo/compiler/dom.js +696 -0
- jongo/compiler/pyrt.js +780 -0
- jongo/compiler/scope.py +114 -0
- jongo/compiler/transpile.py +745 -0
- jongo/db/__init__.py +61 -0
- jongo/db/connection.py +220 -0
- jongo/db/fields.py +558 -0
- jongo/db/migrate.py +479 -0
- jongo/db/models.py +514 -0
- jongo/db/query.py +487 -0
- jongo/errors.py +62 -0
- jongo/errors_page.py +123 -0
- jongo/html.py +50 -0
- jongo/http.py +399 -0
- jongo/routing.py +114 -0
- jongo/rpc.py +215 -0
- jongo/scaffold/app.py.tmpl +92 -0
- jongo/scaffold/dot-gitignore.tmpl +6 -0
- jongo/server.py +172 -0
- jongo/styles.py +105 -0
- jongo/testing.py +140 -0
- jongo/vdom.py +515 -0
- jongo-0.1.0.dist-info/METADATA +336 -0
- jongo-0.1.0.dist-info/RECORD +36 -0
- jongo-0.1.0.dist-info/WHEEL +5 -0
- jongo-0.1.0.dist-info/entry_points.txt +2 -0
- jongo-0.1.0.dist-info/licenses/LICENSE +21 -0
- jongo-0.1.0.dist-info/top_level.txt +1 -0
jongo/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Jongo: a full-stack Python web framework.
|
|
2
|
+
|
|
3
|
+
Pages, server code and reactive UI in one language. Components are plain Python
|
|
4
|
+
that renders on the server and compiles to JavaScript for the browser.
|
|
5
|
+
"""
|
|
6
|
+
from .app import Jongo, Page
|
|
7
|
+
from .errors import CompileError, Forbidden, HTTPError, JongoError, NotFound, ServerError
|
|
8
|
+
from .http import Request, Response, json_response, redirect
|
|
9
|
+
from .rpc import server
|
|
10
|
+
from .styles import css, global_css
|
|
11
|
+
from .vdom import (
|
|
12
|
+
VNode,
|
|
13
|
+
component,
|
|
14
|
+
effect,
|
|
15
|
+
form_values,
|
|
16
|
+
fragment,
|
|
17
|
+
h,
|
|
18
|
+
js,
|
|
19
|
+
navigate,
|
|
20
|
+
raw,
|
|
21
|
+
ref,
|
|
22
|
+
refresh,
|
|
23
|
+
state,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Jongo", "Page", "Request", "Response", "redirect", "json_response",
|
|
30
|
+
"component", "state", "effect", "ref", "navigate", "refresh", "form_values", "js",
|
|
31
|
+
"server", "css", "global_css", "h", "raw", "fragment", "VNode",
|
|
32
|
+
"HTTPError", "NotFound", "Forbidden", "ServerError", "CompileError", "JongoError",
|
|
33
|
+
]
|
jongo/__main__.py
ADDED
jongo/admin.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""An admin site generated from your models.
|
|
2
|
+
|
|
3
|
+
app = Jongo(__name__, database="db.sqlite3")
|
|
4
|
+
app.admin() # -> /admin, for users with is_admin=True
|
|
5
|
+
|
|
6
|
+
Create the first admin with ``jongo createadmin``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import urllib.parse
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
|
|
14
|
+
from . import auth, db
|
|
15
|
+
from .app import Page
|
|
16
|
+
from .errors import NotFound
|
|
17
|
+
from .html import *
|
|
18
|
+
from .http import CSRF_FIELD, redirect
|
|
19
|
+
from .styles import css
|
|
20
|
+
|
|
21
|
+
PER_PAGE = 50
|
|
22
|
+
|
|
23
|
+
S = css(
|
|
24
|
+
shell={
|
|
25
|
+
"display": "grid", "grid_template_columns": "240px 1fr", "min_height": "100vh",
|
|
26
|
+
"font": "14px/1.5 ui-sans-serif, system-ui, -apple-system, sans-serif", "color": "#1d1b20", "background": "#f7f6f3",
|
|
27
|
+
"@media (max-width: 760px)": {"grid_template_columns": "1fr"},
|
|
28
|
+
},
|
|
29
|
+
side={
|
|
30
|
+
"background": "#1c1a1f", "color": "#d8d3dd", "padding": "22px 14px",
|
|
31
|
+
"& a": {"display": "block", "color": "inherit", "text_decoration": "none", "padding": "7px 10px", "border_radius": 8},
|
|
32
|
+
"& a:hover": {"background": "#2c2831", "color": "white"},
|
|
33
|
+
},
|
|
34
|
+
brand={"font_weight": 700, "color": "white", "font_size": 16, "padding": "0 10px 18px", "display": "flex", "gap": 8},
|
|
35
|
+
mark={"color": "#e4572e"},
|
|
36
|
+
here={"background": "#2c2831", "color": "white !important"},
|
|
37
|
+
section_label={"text_transform": "uppercase", "font_size": 11, "letter_spacing": ".08em", "color": "#857e8c",
|
|
38
|
+
"padding": "14px 10px 6px"},
|
|
39
|
+
main={"padding": "26px clamp(16px, 4vw, 44px) 60px", "min_width": 0},
|
|
40
|
+
top={"display": "flex", "justify_content": "space-between", "align_items": "center", "gap": 12, "flex_wrap": "wrap",
|
|
41
|
+
"margin_bottom": 22, "& h1": {"margin": 0, "font_size": 26, "letter_spacing": "-.02em"}},
|
|
42
|
+
user={"display": "flex", "gap": 10, "align_items": "center", "color": "#6f6a74"},
|
|
43
|
+
card={"background": "white", "border": "1px solid #e6e2dc", "border_radius": 12, "overflow": "hidden"},
|
|
44
|
+
grid={"display": "grid", "grid_template_columns": "repeat(auto-fill, minmax(210px, 1fr))", "gap": 14},
|
|
45
|
+
stat={"display": "block", "padding": 18, "text_decoration": "none", "color": "inherit",
|
|
46
|
+
":hover": {"border_color": "#e4572e"}, "& strong": {"display": "block", "font_size": 28, "letter_spacing": "-.02em"}},
|
|
47
|
+
toolbar={"display": "flex", "gap": 8, "flex_wrap": "wrap", "margin_bottom": 14},
|
|
48
|
+
table_wrap={"overflow_x": "auto"},
|
|
49
|
+
table={"width": "100%", "border_collapse": "collapse", "& th, & td": {
|
|
50
|
+
"text_align": "left", "padding": "10px 14px", "border_bottom": "1px solid #efece7", "white_space": "nowrap",
|
|
51
|
+
}, "& th": {"font_size": 12, "color": "#6f6a74", "font_weight": 600, "background": "#fbfaf8"},
|
|
52
|
+
"& th a": {"color": "inherit"}, "& tr:hover td": {"background": "#fdfbf7"}, "& td a": {"color": "#c2410c", "font_weight": 600}},
|
|
53
|
+
input={"padding": "8px 11px", "border": "1px solid #dcd6ce", "border_radius": 8, "font": "inherit", "background": "white",
|
|
54
|
+
"min_width": 0, ":focus": {"outline": "2px solid #fbd0c0", "border_color": "#e4572e"}},
|
|
55
|
+
button={"padding": "8px 14px", "border": "1px solid #1d1b20", "border_radius": 8, "background": "#1d1b20",
|
|
56
|
+
"color": "white", "font": "inherit", "font_weight": 600, "cursor": "pointer", "text_decoration": "none",
|
|
57
|
+
"display": "inline-block"},
|
|
58
|
+
secondary={"background": "white !important", "color": "#1d1b20 !important", "border_color": "#dcd6ce !important"},
|
|
59
|
+
danger={"background": "#b42318 !important", "border_color": "#b42318 !important"},
|
|
60
|
+
link_button={"background": "none", "border": 0, "color": "#6f6a74", "cursor": "pointer", "font": "inherit", "padding": 0,
|
|
61
|
+
":hover": {"color": "#1d1b20"}},
|
|
62
|
+
form={"padding": 22, "display": "grid", "gap": 16, "max_width": 640},
|
|
63
|
+
field={"display": "grid", "gap": 5, "& label": {"font_weight": 600}, "& small": {"color": "#8a8490"},
|
|
64
|
+
"& textarea, & select": {"font": "inherit"}},
|
|
65
|
+
error={"color": "#b42318", "margin": 0, "font_size": 13},
|
|
66
|
+
flash={"background": "#ecfdf3", "border": "1px solid #abefc6", "color": "#067647", "padding": "10px 14px",
|
|
67
|
+
"border_radius": 10, "margin_bottom": 16},
|
|
68
|
+
muted={"color": "#8a8490"},
|
|
69
|
+
pager={"display": "flex", "gap": 10, "align_items": "center", "padding": "12px 14px", "color": "#6f6a74"},
|
|
70
|
+
login={"min_height": "100vh", "display": "grid", "place_items": "center", "background": "#1c1a1f",
|
|
71
|
+
"font": "14px/1.5 ui-sans-serif, system-ui, sans-serif", "padding": 16},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _display(value) -> str:
|
|
76
|
+
if value is None:
|
|
77
|
+
return "—"
|
|
78
|
+
if value is True:
|
|
79
|
+
return "✓"
|
|
80
|
+
if value is False:
|
|
81
|
+
return "✗"
|
|
82
|
+
if isinstance(value, datetime):
|
|
83
|
+
return value.strftime("%Y-%m-%d %H:%M")
|
|
84
|
+
if isinstance(value, date):
|
|
85
|
+
return value.isoformat()
|
|
86
|
+
if isinstance(value, (dict, list)):
|
|
87
|
+
return json.dumps(value)[:60]
|
|
88
|
+
text = str(value)
|
|
89
|
+
return text if len(text) <= 60 else text[:57] + "…"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Admin:
|
|
93
|
+
def __init__(self, app, path="/admin", *, models=None, title="Jongo admin"):
|
|
94
|
+
self.app = app
|
|
95
|
+
self.path = path.rstrip("/")
|
|
96
|
+
self.title = title
|
|
97
|
+
self._models = models
|
|
98
|
+
self._register()
|
|
99
|
+
|
|
100
|
+
# -- helpers --------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def models(self):
|
|
104
|
+
models = self._models if self._models is not None else db.models_registry.values()
|
|
105
|
+
return sorted(models, key=lambda m: m._meta.name)
|
|
106
|
+
|
|
107
|
+
def slug(self, model) -> str:
|
|
108
|
+
return model._meta.name.lower()
|
|
109
|
+
|
|
110
|
+
def model_for(self, slug: str):
|
|
111
|
+
for model in self.models:
|
|
112
|
+
if self.slug(model) == slug.lower():
|
|
113
|
+
return model
|
|
114
|
+
raise NotFound(f"No model called {slug!r}")
|
|
115
|
+
|
|
116
|
+
def url(self, *parts) -> str:
|
|
117
|
+
return "/".join([self.path, *(str(p) for p in parts)])
|
|
118
|
+
|
|
119
|
+
def denied(self, request):
|
|
120
|
+
if not getattr(request.user, "is_admin", False):
|
|
121
|
+
return redirect(f"{self.path}/login?next={urllib.parse.quote(request.full_path)}", 302)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def flash(self, request, message: str):
|
|
125
|
+
request.session["_flash"] = message
|
|
126
|
+
|
|
127
|
+
def csrf(self, request):
|
|
128
|
+
return input_(type="hidden", name=CSRF_FIELD, value=request.csrf_token)
|
|
129
|
+
|
|
130
|
+
def shell(self, request, heading: str, *content, actions=None, current=None) -> Page:
|
|
131
|
+
message = request.session.pop("_flash") if "_flash" in request.session else None
|
|
132
|
+
side = aside(
|
|
133
|
+
div(span("◆", class_=S.mark), self.title, class_=S.brand),
|
|
134
|
+
a("Dashboard", href=self.path, class_={S.here: current is None}),
|
|
135
|
+
div("Models", class_=S.section_label),
|
|
136
|
+
[
|
|
137
|
+
a(model._meta.verbose_name_plural.capitalize(), href=self.url(self.slug(model)),
|
|
138
|
+
class_={S.here: current is model})
|
|
139
|
+
for model in self.models
|
|
140
|
+
],
|
|
141
|
+
div("Site", class_=S.section_label),
|
|
142
|
+
a("View site ↗", href="/", data_reload=True),
|
|
143
|
+
class_=S.side,
|
|
144
|
+
)
|
|
145
|
+
top = div(
|
|
146
|
+
h1(heading),
|
|
147
|
+
div(
|
|
148
|
+
actions,
|
|
149
|
+
span(str(request.user)),
|
|
150
|
+
form(self.csrf(request), button("Log out", class_=S.link_button), method="post",
|
|
151
|
+
action=self.url("logout")),
|
|
152
|
+
class_=S.user,
|
|
153
|
+
),
|
|
154
|
+
class_=S.top,
|
|
155
|
+
)
|
|
156
|
+
body = main(top, message and div(message, class_=S.flash), *content, class_=S.main)
|
|
157
|
+
return Page(div(side, body, class_=S.shell), title=f"{heading} · {self.title}")
|
|
158
|
+
|
|
159
|
+
# -- forms ------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def editable_fields(self, model):
|
|
162
|
+
return [f for f in model._meta.fields if f.editable]
|
|
163
|
+
|
|
164
|
+
def control(self, field, value):
|
|
165
|
+
name = field.name
|
|
166
|
+
attrs = {"id": f"f-{name}", "name": name, "class_": S.input}
|
|
167
|
+
text = "" if value is None else str(value)
|
|
168
|
+
if field.kind == "bool":
|
|
169
|
+
return input_(type="checkbox", checked=value not in (None, False, "", "false", "0"), id=f"f-{name}", name=name)
|
|
170
|
+
if field.choices:
|
|
171
|
+
options = [] if field.required else [option("—", value="")]
|
|
172
|
+
options += [option(label, value=str(v), selected=str(v) == text) for v, label in field.choices]
|
|
173
|
+
return select(options, **attrs)
|
|
174
|
+
if field.kind == "fk":
|
|
175
|
+
options = [option("—", value="")] if field.null else []
|
|
176
|
+
options += [
|
|
177
|
+
option(str(obj), value=str(obj.pk), selected=str(obj.pk) == text)
|
|
178
|
+
for obj in field.related_model.all()[:500]
|
|
179
|
+
]
|
|
180
|
+
return select(options, **attrs)
|
|
181
|
+
if field.kind in ("int", "float"):
|
|
182
|
+
return input_(type="number", value=text, step="1" if field.kind == "int" else "any", **attrs)
|
|
183
|
+
if field.kind == "datetime":
|
|
184
|
+
if isinstance(value, datetime):
|
|
185
|
+
text = value.strftime("%Y-%m-%dT%H:%M")
|
|
186
|
+
return input_(type="datetime-local", value=text, **attrs)
|
|
187
|
+
if field.kind == "date":
|
|
188
|
+
return input_(type="date", value=text, **attrs)
|
|
189
|
+
if field.kind == "json":
|
|
190
|
+
if not isinstance(value, str):
|
|
191
|
+
text = json.dumps(value, indent=2) if value is not None else ""
|
|
192
|
+
return textarea(value=text, rows=6, **attrs)
|
|
193
|
+
if getattr(field, "max_length", None) is None:
|
|
194
|
+
return textarea(value=text, rows=4, **attrs)
|
|
195
|
+
return input_(type="email" if "email" in name else "text", value=text, maxlength=field.max_length, **attrs)
|
|
196
|
+
|
|
197
|
+
def form_view(self, request, model, obj, values, errors):
|
|
198
|
+
is_user = model is auth.User
|
|
199
|
+
fields = []
|
|
200
|
+
for field in self.editable_fields(model):
|
|
201
|
+
fields.append(
|
|
202
|
+
div(
|
|
203
|
+
field.kind == "bool" and label(self.control(field, values.get(field.name)), " ", field.label)
|
|
204
|
+
or [label(field.label, for_=f"f-{field.name}"), self.control(field, values.get(field.name))],
|
|
205
|
+
field.help and small(field.help),
|
|
206
|
+
errors.get(field.name) and p(errors[field.name], class_=S.error),
|
|
207
|
+
class_=S.field,
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
if is_user:
|
|
211
|
+
fields.append(div(
|
|
212
|
+
label("New password" if obj.pk else "Password", for_="f-password"),
|
|
213
|
+
input_(type="password", id="f-password", name="_password", autocomplete="new-password", class_=S.input),
|
|
214
|
+
small("Leave blank to keep the current password") if obj.pk else None,
|
|
215
|
+
class_=S.field,
|
|
216
|
+
))
|
|
217
|
+
readonly = [f for f in model._meta.fields if not f.editable and f.name != "password" and obj.pk]
|
|
218
|
+
return div(
|
|
219
|
+
errors.get("__all__") and p(errors["__all__"], class_=S.error),
|
|
220
|
+
form(
|
|
221
|
+
self.csrf(request),
|
|
222
|
+
fields,
|
|
223
|
+
readonly and div([p(strong(f.label, ": "), _display(getattr(obj, f.name))) for f in readonly],
|
|
224
|
+
class_=S.muted),
|
|
225
|
+
div(
|
|
226
|
+
button("Save", class_=S.button),
|
|
227
|
+
" ",
|
|
228
|
+
a("Cancel", href=self.url(self.slug(model)), class_=[S.button, S.secondary]),
|
|
229
|
+
obj.pk and [" ", a("Delete", href=self.url(self.slug(model), obj.pk, "delete"),
|
|
230
|
+
class_=[S.button, S.danger])],
|
|
231
|
+
),
|
|
232
|
+
method="post",
|
|
233
|
+
class_=S.form,
|
|
234
|
+
),
|
|
235
|
+
class_=S.card,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def save_from_form(self, request, model, obj):
|
|
239
|
+
errors = {}
|
|
240
|
+
for field in self.editable_fields(model):
|
|
241
|
+
try:
|
|
242
|
+
setattr(obj, field.attname, field.clean(request.form.get(field.name)))
|
|
243
|
+
except db.ValidationError as exc:
|
|
244
|
+
errors[field.name] = getattr(exc, "message", None) or str(exc)
|
|
245
|
+
new_password = request.form.get("_password")
|
|
246
|
+
if model is auth.User:
|
|
247
|
+
if new_password:
|
|
248
|
+
obj.set_password(new_password)
|
|
249
|
+
elif not obj.pk:
|
|
250
|
+
errors["_password"] = errors["__all__"] = "Set a password for the new user"
|
|
251
|
+
if not errors:
|
|
252
|
+
try:
|
|
253
|
+
obj.save()
|
|
254
|
+
except db.ValidationError as exc:
|
|
255
|
+
errors.update(exc.errors)
|
|
256
|
+
return errors
|
|
257
|
+
|
|
258
|
+
# -- routes -------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def _register(self):
|
|
261
|
+
app, admin = self.app, self
|
|
262
|
+
|
|
263
|
+
@app.page(f"{self.path}/login", layout=False, title="Log in")
|
|
264
|
+
def admin_login(request, next: str = ""):
|
|
265
|
+
return admin.login_page(request, next)
|
|
266
|
+
|
|
267
|
+
@app.post(f"{self.path}/login")
|
|
268
|
+
def admin_login_post(request):
|
|
269
|
+
username = request.form.get("username", "")
|
|
270
|
+
user = auth.authenticate(username, request.form.get("password", ""))
|
|
271
|
+
destination = request.form.get("next") or admin.path
|
|
272
|
+
if not destination.startswith("/") or destination.startswith("//"):
|
|
273
|
+
destination = admin.path
|
|
274
|
+
if user is None or not user.is_admin:
|
|
275
|
+
return app.render_page(request, admin.login_page(request, destination, "Wrong username or password, or not an admin."))
|
|
276
|
+
auth.login(request, user)
|
|
277
|
+
return redirect(destination)
|
|
278
|
+
|
|
279
|
+
@app.post(f"{self.path}/logout")
|
|
280
|
+
def admin_logout(request):
|
|
281
|
+
auth.logout(request)
|
|
282
|
+
return redirect(f"{admin.path}/login")
|
|
283
|
+
|
|
284
|
+
@app.page(self.path, layout=False)
|
|
285
|
+
def admin_dashboard(request):
|
|
286
|
+
return admin.denied(request) or admin.shell(
|
|
287
|
+
request,
|
|
288
|
+
"Dashboard",
|
|
289
|
+
div(
|
|
290
|
+
[
|
|
291
|
+
a(span(m._meta.verbose_name_plural.capitalize(), class_=S.muted), strong(str(m.objects.count())),
|
|
292
|
+
href=admin.url(admin.slug(m)), class_=[S.card, S.stat])
|
|
293
|
+
for m in admin.models
|
|
294
|
+
],
|
|
295
|
+
class_=S.grid,
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
@app.page(f"{self.path}/<model>", layout=False)
|
|
300
|
+
def admin_list(request, model: str, q: str = "", page: int = 1, o: str = ""):
|
|
301
|
+
return admin.denied(request) or admin.list_page(request, admin.model_for(model), q, page, o)
|
|
302
|
+
|
|
303
|
+
@app.page(f"{self.path}/<model>/new", layout=False)
|
|
304
|
+
def admin_new(request, model: str):
|
|
305
|
+
if denied := admin.denied(request):
|
|
306
|
+
return denied
|
|
307
|
+
cls = admin.model_for(model)
|
|
308
|
+
obj = cls()
|
|
309
|
+
values = {f.name: getattr(obj, f.attname, None) for f in admin.editable_fields(cls)}
|
|
310
|
+
return admin.shell(request, f"New {cls._meta.verbose_name}", admin.form_view(request, cls, obj, values, {}),
|
|
311
|
+
current=cls)
|
|
312
|
+
|
|
313
|
+
@app.post(f"{self.path}/<model>/new")
|
|
314
|
+
def admin_create(request, model: str):
|
|
315
|
+
if denied := admin.denied(request):
|
|
316
|
+
return denied
|
|
317
|
+
cls = admin.model_for(model)
|
|
318
|
+
obj = cls()
|
|
319
|
+
errors = admin.save_from_form(request, cls, obj)
|
|
320
|
+
if errors:
|
|
321
|
+
page = admin.shell(request, f"New {cls._meta.verbose_name}",
|
|
322
|
+
admin.form_view(request, cls, obj, dict(request.form), errors), current=cls)
|
|
323
|
+
page.status = 400
|
|
324
|
+
return app.render_page(request, page)
|
|
325
|
+
admin.flash(request, f"Created {obj}.")
|
|
326
|
+
return redirect(admin.url(model))
|
|
327
|
+
|
|
328
|
+
@app.page(f"{self.path}/<model>/<id>", layout=False)
|
|
329
|
+
def admin_edit(request, model: str, id: int):
|
|
330
|
+
if denied := admin.denied(request):
|
|
331
|
+
return denied
|
|
332
|
+
cls = admin.model_for(model)
|
|
333
|
+
obj = cls.filter(id=id).first() or admin._missing(cls, id)
|
|
334
|
+
values = {f.name: getattr(obj, f.attname) for f in admin.editable_fields(cls)}
|
|
335
|
+
return admin.shell(request, str(obj), admin.form_view(request, cls, obj, values, {}), current=cls)
|
|
336
|
+
|
|
337
|
+
@app.post(f"{self.path}/<model>/<id>")
|
|
338
|
+
def admin_update(request, model: str, id: int):
|
|
339
|
+
if denied := admin.denied(request):
|
|
340
|
+
return denied
|
|
341
|
+
cls = admin.model_for(model)
|
|
342
|
+
obj = cls.filter(id=id).first() or admin._missing(cls, id)
|
|
343
|
+
errors = admin.save_from_form(request, cls, obj)
|
|
344
|
+
if errors:
|
|
345
|
+
page = admin.shell(request, str(obj), admin.form_view(request, cls, obj, dict(request.form), errors),
|
|
346
|
+
current=cls)
|
|
347
|
+
page.status = 400
|
|
348
|
+
return app.render_page(request, page)
|
|
349
|
+
admin.flash(request, f"Saved {obj}.")
|
|
350
|
+
return redirect(admin.url(model))
|
|
351
|
+
|
|
352
|
+
@app.page(f"{self.path}/<model>/<id>/delete", layout=False)
|
|
353
|
+
def admin_confirm_delete(request, model: str, id: int):
|
|
354
|
+
if denied := admin.denied(request):
|
|
355
|
+
return denied
|
|
356
|
+
cls = admin.model_for(model)
|
|
357
|
+
obj = cls.filter(id=id).first() or admin._missing(cls, id)
|
|
358
|
+
return admin.shell(
|
|
359
|
+
request,
|
|
360
|
+
f"Delete {obj}?",
|
|
361
|
+
div(
|
|
362
|
+
form(
|
|
363
|
+
p("This can't be undone."),
|
|
364
|
+
admin.csrf(request),
|
|
365
|
+
button("Delete", class_=[S.button, S.danger]),
|
|
366
|
+
" ",
|
|
367
|
+
a("Cancel", href=admin.url(model, id), class_=[S.button, S.secondary]),
|
|
368
|
+
method="post",
|
|
369
|
+
class_=S.form,
|
|
370
|
+
),
|
|
371
|
+
class_=S.card,
|
|
372
|
+
),
|
|
373
|
+
current=cls,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
@app.post(f"{self.path}/<model>/<id>/delete")
|
|
377
|
+
def admin_delete(request, model: str, id: int):
|
|
378
|
+
if denied := admin.denied(request):
|
|
379
|
+
return denied
|
|
380
|
+
cls = admin.model_for(model)
|
|
381
|
+
obj = cls.filter(id=id).first() or admin._missing(cls, id)
|
|
382
|
+
label = str(obj)
|
|
383
|
+
obj.delete()
|
|
384
|
+
admin.flash(request, f"Deleted {label}.")
|
|
385
|
+
return redirect(admin.url(model))
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _missing(model, pk):
|
|
389
|
+
raise NotFound(f"{model._meta.verbose_name.capitalize()} #{pk} doesn't exist")
|
|
390
|
+
|
|
391
|
+
# -- pages ----------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
def login_page(self, request, next_url="", error=None):
|
|
394
|
+
return Page(
|
|
395
|
+
div(
|
|
396
|
+
form(
|
|
397
|
+
div(span("◆", class_=S.mark), self.title, class_=S.brand, style={"color": "#1d1b20", "padding": 0}),
|
|
398
|
+
error and p(error, class_=S.error),
|
|
399
|
+
self.csrf(request),
|
|
400
|
+
input_(type="hidden", name="next", value=next_url),
|
|
401
|
+
div(label("Username", for_="u"), input_(id="u", name="username", autofocus=True, class_=S.input),
|
|
402
|
+
class_=S.field),
|
|
403
|
+
div(label("Password", for_="p"), input_(id="p", name="password", type="password", class_=S.input),
|
|
404
|
+
class_=S.field),
|
|
405
|
+
button("Log in", class_=S.button),
|
|
406
|
+
method="post",
|
|
407
|
+
action=self.url("login"),
|
|
408
|
+
class_=[S.card, S.form],
|
|
409
|
+
style={"width": "min(360px, 100%)"},
|
|
410
|
+
),
|
|
411
|
+
class_=S.login,
|
|
412
|
+
),
|
|
413
|
+
title=f"Log in · {self.title}",
|
|
414
|
+
status=400 if error else 200,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
def list_page(self, request, model, q, page, order):
|
|
418
|
+
meta = model._meta
|
|
419
|
+
fields = [f for f in meta.fields if f.kind != "json" and f.name != "password"][:5]
|
|
420
|
+
queryset = model.objects.all()
|
|
421
|
+
text_fields = [f.name for f in meta.fields if f.kind == "text" and f.name != "password"]
|
|
422
|
+
if q and text_fields:
|
|
423
|
+
queryset = queryset.search(q, text_fields)
|
|
424
|
+
sortable = {f.name for f in fields} | {"id"}
|
|
425
|
+
if order.lstrip("-") in sortable:
|
|
426
|
+
queryset = queryset.order_by(order)
|
|
427
|
+
total = queryset.count()
|
|
428
|
+
page = max(1, page)
|
|
429
|
+
rows = list(queryset[(page - 1) * PER_PAGE : page * PER_PAGE])
|
|
430
|
+
slug = self.slug(model)
|
|
431
|
+
|
|
432
|
+
def sort_link(field):
|
|
433
|
+
new = field.name if order != field.name else f"-{field.name}"
|
|
434
|
+
arrow = " ↑" if order == field.name else " ↓" if order == f"-{field.name}" else ""
|
|
435
|
+
query = urllib.parse.urlencode({k: v for k, v in (("q", q), ("o", new)) if v})
|
|
436
|
+
return a(field.label + arrow, href=f"{self.url(slug)}?{query}")
|
|
437
|
+
|
|
438
|
+
def page_link(number, text):
|
|
439
|
+
query = urllib.parse.urlencode({k: v for k, v in (("q", q), ("o", order), ("page", number)) if v})
|
|
440
|
+
return a(text, href=f"{self.url(slug)}?{query}")
|
|
441
|
+
|
|
442
|
+
table_ = table(
|
|
443
|
+
thead(tr(th(meta.verbose_name.capitalize()), [th(sort_link(f)) for f in fields])),
|
|
444
|
+
tbody(
|
|
445
|
+
[
|
|
446
|
+
tr(
|
|
447
|
+
td(a(_display(str(obj)), href=self.url(slug, obj.pk))),
|
|
448
|
+
[td(_display(getattr(obj, f.name))) for f in fields],
|
|
449
|
+
key=obj.pk,
|
|
450
|
+
)
|
|
451
|
+
for obj in rows
|
|
452
|
+
]
|
|
453
|
+
or tr(td(f"No {meta.verbose_name_plural} yet." if not q else "Nothing matches.",
|
|
454
|
+
colspan=len(fields) + 1, class_=S.muted))
|
|
455
|
+
),
|
|
456
|
+
class_=S.table,
|
|
457
|
+
)
|
|
458
|
+
pages = max(1, -(-total // PER_PAGE))
|
|
459
|
+
return self.shell(
|
|
460
|
+
request,
|
|
461
|
+
meta.verbose_name_plural.capitalize(),
|
|
462
|
+
form(
|
|
463
|
+
input_(name="q", value=q, placeholder=f"Search {meta.verbose_name_plural}", class_=S.input,
|
|
464
|
+
type="search", disabled=not text_fields),
|
|
465
|
+
button("Search", class_=[S.button, S.secondary]),
|
|
466
|
+
method="get",
|
|
467
|
+
class_=S.toolbar,
|
|
468
|
+
),
|
|
469
|
+
div(
|
|
470
|
+
div(table_, class_=S.table_wrap),
|
|
471
|
+
div(
|
|
472
|
+
f"{total} {meta.verbose_name if total == 1 else meta.verbose_name_plural}",
|
|
473
|
+
page > 1 and page_link(page - 1, "← Previous"),
|
|
474
|
+
pages > 1 and span(f"Page {page} of {pages}"),
|
|
475
|
+
page < pages and page_link(page + 1, "Next →"),
|
|
476
|
+
class_=S.pager,
|
|
477
|
+
),
|
|
478
|
+
class_=S.card,
|
|
479
|
+
),
|
|
480
|
+
actions=a(f"Add {meta.verbose_name}", href=self.url(slug, "new"), class_=S.button),
|
|
481
|
+
current=model,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def install(app, path="/admin", *, models=None, title="Jongo admin") -> Admin:
|
|
486
|
+
return Admin(app, path, models=models, title=title)
|