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 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
@@ -0,0 +1,3 @@
1
+ from jongo.cli import main
2
+
3
+ main()
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)