tungsten-admin 0.1.0__tar.gz
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.
- tungsten_admin-0.1.0/.gitignore +12 -0
- tungsten_admin-0.1.0/LICENSE +21 -0
- tungsten_admin-0.1.0/PKG-INFO +581 -0
- tungsten_admin-0.1.0/README.md +548 -0
- tungsten_admin-0.1.0/pyproject.toml +52 -0
- tungsten_admin-0.1.0/src/tungsten/__init__.py +53 -0
- tungsten_admin-0.1.0/src/tungsten/actions/__init__.py +25 -0
- tungsten_admin-0.1.0/src/tungsten/actions/action.py +432 -0
- tungsten_admin-0.1.0/src/tungsten/actions/prebuilt.py +396 -0
- tungsten_admin-0.1.0/src/tungsten/auth/__init__.py +333 -0
- tungsten_admin-0.1.0/src/tungsten/auth/profile.py +73 -0
- tungsten_admin-0.1.0/src/tungsten/auth/rbac.py +138 -0
- tungsten_admin-0.1.0/src/tungsten/auth/two_factor.py +147 -0
- tungsten_admin-0.1.0/src/tungsten/auth/verification.py +91 -0
- tungsten_admin-0.1.0/src/tungsten/cli/__init__.py +352 -0
- tungsten_admin-0.1.0/src/tungsten/cli/stubs.py +251 -0
- tungsten_admin-0.1.0/src/tungsten/context.py +112 -0
- tungsten_admin-0.1.0/src/tungsten/forms/__init__.py +36 -0
- tungsten_admin-0.1.0/src/tungsten/forms/base.py +57 -0
- tungsten_admin-0.1.0/src/tungsten/forms/fields.py +1960 -0
- tungsten_admin-0.1.0/src/tungsten/forms/form.py +292 -0
- tungsten_admin-0.1.0/src/tungsten/forms/layout.py +341 -0
- tungsten_admin-0.1.0/src/tungsten/hosts.py +343 -0
- tungsten_admin-0.1.0/src/tungsten/i18n.py +284 -0
- tungsten_admin-0.1.0/src/tungsten/importexport.py +413 -0
- tungsten_admin-0.1.0/src/tungsten/infolists.py +263 -0
- tungsten_admin-0.1.0/src/tungsten/lang/hi.json +362 -0
- tungsten_admin-0.1.0/src/tungsten/models.py +97 -0
- tungsten_admin-0.1.0/src/tungsten/navigation.py +37 -0
- tungsten_admin-0.1.0/src/tungsten/notifications.py +120 -0
- tungsten_admin-0.1.0/src/tungsten/pages.py +222 -0
- tungsten_admin-0.1.0/src/tungsten/panel.py +494 -0
- tungsten_admin-0.1.0/src/tungsten/plugins.py +35 -0
- tungsten_admin-0.1.0/src/tungsten/rendering.py +71 -0
- tungsten_admin-0.1.0/src/tungsten/resources/__init__.py +4 -0
- tungsten_admin-0.1.0/src/tungsten/resources/relation_manager.py +88 -0
- tungsten_admin-0.1.0/src/tungsten/resources/resource.py +232 -0
- tungsten_admin-0.1.0/src/tungsten/routes.py +1305 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/favicon.svg +1 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/tungsten.css +1 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/tungsten.js +330 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/alpine.min.js +21 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/chart.umd.min.js +14 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/htmx.min.js +1 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/qrcode.js +2297 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/sortable.min.js +2 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/tom-select.complete.min.js +454 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/tom-select.css +413 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/trix.css +470 -0
- tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/trix.umd.min.js +7 -0
- tungsten_admin-0.1.0/src/tungsten/storage.py +69 -0
- tungsten_admin-0.1.0/src/tungsten/support/__init__.py +0 -0
- tungsten_admin-0.1.0/src/tungsten/support/aio.py +71 -0
- tungsten_admin-0.1.0/src/tungsten/support/colors.py +172 -0
- tungsten_admin-0.1.0/src/tungsten/support/component.py +57 -0
- tungsten_admin-0.1.0/src/tungsten/support/evaluate.py +52 -0
- tungsten_admin-0.1.0/src/tungsten/support/html.py +85 -0
- tungsten_admin-0.1.0/src/tungsten/support/icons.json +1 -0
- tungsten_admin-0.1.0/src/tungsten/support/icons.py +69 -0
- tungsten_admin-0.1.0/src/tungsten/support/palettes.py +26 -0
- tungsten_admin-0.1.0/src/tungsten/support/state.py +99 -0
- tungsten_admin-0.1.0/src/tungsten/tables/__init__.py +38 -0
- tungsten_admin-0.1.0/src/tungsten/tables/columns.py +849 -0
- tungsten_admin-0.1.0/src/tungsten/tables/filters.py +365 -0
- tungsten_admin-0.1.0/src/tungsten/tables/query_builder.py +617 -0
- tungsten_admin-0.1.0/src/tungsten/tables/summarizers.py +81 -0
- tungsten_admin-0.1.0/src/tungsten/tables/table.py +826 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/actions/button.html +21 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/actions/group.html +14 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/actions/modal.html +47 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/forgot-password.html +19 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/login.html +21 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/register.html +19 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/reset-password.html +17 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/two-factor-challenge.html +15 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/two-factor-setup.html +69 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/verify-email.html +36 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/brand.html +10 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/global-search.html +13 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/notification-badge.html +1 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/notification-bell.html +11 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/notifications.html +32 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/page-filters.html +6 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/page-header.html +14 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/search-results.html +34 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/toasts.html +21 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/ui.html +182 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/widgets.html +16 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/checkbox-list.html +26 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/checkbox.html +10 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/color-picker.html +12 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/date-picker.html +8 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/file-upload.html +50 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/hidden.html +1 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/key-value.html +32 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/permissions.html +34 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/placeholder.html +4 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/radio.html +16 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/repeater.html +75 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/rich-editor.html +15 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/select.html +26 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/tags-input.html +30 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/text-input.html +33 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/textarea.html +10 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/toggle-buttons.html +28 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/toggle.html +23 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/form.html +7 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/grid.html +6 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/layouts/fieldset.html +4 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/layouts/group.html +1 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/layouts/section.html +26 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/layouts/tabs.html +19 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/layouts/wizard.html +41 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/macros.html +46 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/infolists/entry.html +9 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/infolists/infolist.html +3 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/infolists/key-value-entry.html +15 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/infolists/repeatable-entry.html +8 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/layout/app.html +167 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/layout/auth.html +36 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/layout/base.html +53 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/pages/dashboard.html +13 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/pages/error.html +14 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/pages/page.html +19 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/pages/profile.html +23 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/resources/list.html +8 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/resources/record.html +54 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/checkbox.html +5 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/color.html +3 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/icon.html +3 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/image.html +12 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/select.html +8 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/text-input.html +6 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/text.html +33 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/toggle.html +11 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/query-builder.html +74 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/table.html +253 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/widgets/account.html +9 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/widgets/chart.html +37 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/widgets/progress.html +21 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/widgets/stats.html +7 -0
- tungsten_admin-0.1.0/src/tungsten/templates/tungsten/widgets/table.html +14 -0
- tungsten_admin-0.1.0/src/tungsten/tenancy.py +102 -0
- tungsten_admin-0.1.0/src/tungsten/widgets.py +269 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Prism Infoways
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tungsten-admin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Filament-style admin panel builder for FastAPI: resources, forms, tables, actions, widgets, auth and more.
|
|
5
|
+
Author: Prism Infoways
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: admin,crud,fastapi,filament,htmx,panel,sqlalchemy
|
|
9
|
+
Classifier: Framework :: FastAPI
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: fastapi>=0.110
|
|
15
|
+
Requires-Dist: itsdangerous>=2.1
|
|
16
|
+
Requires-Dist: jinja2>=3.1
|
|
17
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
18
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
19
|
+
Requires-Dist: starlette>=0.37
|
|
20
|
+
Requires-Dist: typer>=0.12
|
|
21
|
+
Provides-Extra: async
|
|
22
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'async'
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: aiosqlite>=0.19; extra == 'dev'
|
|
25
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
26
|
+
Requires-Dist: openpyxl>=3.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: uvicorn>=0.29; extra == 'dev'
|
|
30
|
+
Provides-Extra: excel
|
|
31
|
+
Requires-Dist: openpyxl>=3.1; extra == 'excel'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# Tungsten
|
|
35
|
+
|
|
36
|
+
**A Filament-style admin panel for FastAPI.** Describe a model once in Python and get list, create, edit and view pages, with search, filters, bulk actions, modals, dashboards, login and roles.
|
|
37
|
+
|
|
38
|
+
Tungsten renders HTML on the server (Jinja2) and uses HTMX + Alpine.js in the browser. You write no JavaScript. It works with SQLAlchemy 2.0 models (SQLModel models work too).
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
class ProductResource(Resource):
|
|
42
|
+
model = Product
|
|
43
|
+
icon = "package"
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def form(cls, form):
|
|
47
|
+
return form.schema([
|
|
48
|
+
TextInput("name").required().max_length(150),
|
|
49
|
+
Select("category_id").relationship("category", "name").searchable(),
|
|
50
|
+
TextInput("price").numeric().prefix("₹"),
|
|
51
|
+
Toggle("is_featured"),
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def table(cls, table):
|
|
56
|
+
return (
|
|
57
|
+
table.columns([
|
|
58
|
+
TextColumn("name").searchable().sortable(),
|
|
59
|
+
TextColumn("category.name").badge(),
|
|
60
|
+
TextColumn("price").money("INR").sortable(),
|
|
61
|
+
])
|
|
62
|
+
.filters([SelectFilter("category").relationship("category", "name")])
|
|
63
|
+
.actions([EditAction(), DeleteAction()])
|
|
64
|
+
.bulk_actions([DeleteBulkAction()])
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
| Area | What you get |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| **Resources** | List / create / edit / view pages from one class. Soft delete with restore and force delete. "Simple" resources that work fully in popups. |
|
|
75
|
+
| **Infolists** | Read-only view pages with entries (text, badge, icon, image, color, key-value, repeatable) in the same layouts as forms. |
|
|
76
|
+
| **Forms** | TextInput, Textarea, Select (searchable, multiple, relationship), CheckboxList, Checkbox, Toggle, ToggleButtons, Radio, DatePicker, DateTimePicker, TimePicker, FileUpload (images, multiple), RichEditor, ColorPicker, TagsInput, Repeater (JSON or related rows), Builder (content blocks), KeyValue, Hidden, Placeholder. |
|
|
77
|
+
| **Layouts** | Section (cards, collapsible, aside), Grid, Fieldset, Group, Tabs, Wizard (step-by-step, validates each step). |
|
|
78
|
+
| **Validation** | Required, length, min/max, email, URL, regex, unique, "same as", custom rules. Clear messages under each field. |
|
|
79
|
+
| **Dependent fields** | `.live()` fields re-render the form on the server: show/hide fields, change options, fill other fields. |
|
|
80
|
+
| **Tables** | Text, badge, image, icon, color columns, plus inline-editable toggle, checkbox, text input and select columns. Search (also through relations), sort, filters, a query builder (users build their own AND/OR rules), list tabs with counts, pagination, row and bulk actions, show/hide columns, grouping, totals, drag-to-reorder rows. |
|
|
81
|
+
| **Actions** | Buttons, confirm boxes and modal forms. Ready-made create, edit, view, delete, restore, replicate, attach and detach actions. |
|
|
82
|
+
| **Relations** | Relation managers: manage a customer's orders, or attach tags to a product, on the record page. |
|
|
83
|
+
| **Widgets** | Stats cards with trends and sparklines, charts (line, bar, pie, doughnut...), table widgets, progress lists. Loaded lazily, optional polling. Dashboard filters (e.g. "Last 30 days") passed to every widget. |
|
|
84
|
+
| **Auth & roles** | Login, sign-up, email verification, forgot/reset password, profile page, two-factor login (TOTP + recovery codes), role-based permissions with a Roles screen, policies or a custom gate. |
|
|
85
|
+
| **Notifications** | Toast messages and an in-app notification bell (stored in the database). |
|
|
86
|
+
| **Navigation** | Sidebar groups, icons, badge counts, nested items, ⌘K global search across records and pages. |
|
|
87
|
+
| **Theming & UX** | Brand colors (any Tailwind palette or a hex color), logo, dark mode, SPA mode (no full page reloads), unsaved-changes warning, collapsible sidebar, keyboard shortcuts (Ctrl/⌘+S saves). |
|
|
88
|
+
| **Languages** | Every screen can be translated. Hindi ships built in; add any language with a JSON file. Users switch language from the user menu or the login page. |
|
|
89
|
+
| **Database** | Works with a normal SQLAlchemy engine or an async one (`create_async_engine`). `async def` hooks work too. |
|
|
90
|
+
| **Extras** | CSV/Excel import and export, custom pages, multi-tenancy (teams/companies), plugins, render hooks, CLI generators. |
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Install
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install tungsten-admin # add [excel] for .xlsx import/export, [async] for async engines
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Quick start
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from fastapi import FastAPI
|
|
104
|
+
from sqlalchemy import create_engine
|
|
105
|
+
from sqlalchemy.orm import sessionmaker
|
|
106
|
+
from tungsten import Auth, Panel
|
|
107
|
+
|
|
108
|
+
engine = create_engine("sqlite:///app.db")
|
|
109
|
+
SessionLocal = sessionmaker(engine, expire_on_commit=False)
|
|
110
|
+
|
|
111
|
+
panel = Panel(
|
|
112
|
+
path="/admin",
|
|
113
|
+
session_factory=SessionLocal,
|
|
114
|
+
secret_key="change-me", # signs the session cookie
|
|
115
|
+
auth=Auth(User), # needs email + password (hash) fields
|
|
116
|
+
brand_name="Acme",
|
|
117
|
+
colors={"primary": "orange"}, # or a hex color: "#ec5b1d"
|
|
118
|
+
)
|
|
119
|
+
panel.resources([ProductResource, OrderResource])
|
|
120
|
+
panel.widgets([StatsWidget, RevenueChart])
|
|
121
|
+
panel.rbac() # optional: roles & permissions
|
|
122
|
+
panel.create_tables(engine) # Tungsten's own tables (roles, notifications...)
|
|
123
|
+
|
|
124
|
+
app = FastAPI()
|
|
125
|
+
panel.mount(app)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Create your first user:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
tungsten make:user --panel app.admin:panel --role "Super Admin"
|
|
132
|
+
tungsten lang:extract hi --path app # collect text to translate into lang/hi.json
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Passwords are hashed with PBKDF2-SHA256 (`tungsten.hash_password`). Pass `hasher=` to `Auth` to use your own.
|
|
136
|
+
|
|
137
|
+
## Try the demo
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
git clone … && cd tungsten
|
|
141
|
+
pip install -e ".[dev]"
|
|
142
|
+
python -m examples.shop.seed
|
|
143
|
+
uvicorn examples.shop.app:app --reload
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Open <http://127.0.0.1:8000/admin> and sign in with **admin@example.com / password**. The demo has users (wizard on create, tabs on edit), products (tabs, images, tags, import/export), orders (repeater with live prices), customers (dependent state → city, orders relation manager), roles, a settings page and a full dashboard. Switch to Hindi from the user menu, try the **Custom filters** query builder on Products, and set `DATABASE_URL=sqlite+aiosqlite:///shop.db` to run it on an async engine.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Guide
|
|
151
|
+
|
|
152
|
+
### Closures get what they ask for
|
|
153
|
+
|
|
154
|
+
Almost every option takes a value **or** a function. Tungsten looks at the parameter names and passes what you ask for: `get`, `set`, `state`, `record`, `operation` (`"create"`, `"edit"`, `"view"`), `user`, `ctx`, `db`, `request`, `tenant`, `data`, `records`, `query`, `model`.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
Select("city").options(lambda get: CITIES[get("state")]).visible(lambda get: bool(get("state")))
|
|
158
|
+
TextInput("password").required(lambda operation: operation == "create")
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Resources
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
class OrderResource(Resource):
|
|
165
|
+
model = Order
|
|
166
|
+
icon = "shopping-cart" # any Lucide icon name
|
|
167
|
+
navigation_group = "Shop"
|
|
168
|
+
navigation_sort = 3
|
|
169
|
+
record_title_attribute = "number"
|
|
170
|
+
global_search_attributes = ["number", "customer.name"]
|
|
171
|
+
relations = [ItemsRelationManager]
|
|
172
|
+
widgets = [OrderStats] # shown above the list
|
|
173
|
+
simple = False # True = create/edit in popups
|
|
174
|
+
policy = None # object with view_any/update/delete(user, record) ...
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def navigation_badge(cls, db): # number next to the menu item
|
|
178
|
+
return db.scalar(select(func.count()).where(Order.status == "pending"))
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def query(cls, ctx): # scope every page
|
|
182
|
+
return select(Order).where(Order.archived.is_(False))
|
|
183
|
+
|
|
184
|
+
# hooks
|
|
185
|
+
@classmethod
|
|
186
|
+
def after_create(cls, record, db, ctx): ...
|
|
187
|
+
@classmethod
|
|
188
|
+
def after_save(cls, record, db): ...
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Other hooks: `mutate_form_data_before_create`, `before_create`, `mutate_form_data_before_save`, `before_save`, `before_delete`, `after_delete`. Soft deletes turn on by themselves when the model has a `deleted_at` column.
|
|
192
|
+
|
|
193
|
+
Change the header buttons with `header_actions(cls, ctx, page, record)`.
|
|
194
|
+
|
|
195
|
+
### Forms
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
form.columns(3).schema([
|
|
199
|
+
Tabs().column_span(2).tabs([
|
|
200
|
+
Tab("General").schema([
|
|
201
|
+
TextInput("name").required(),
|
|
202
|
+
RichEditor("description").column_span("full"),
|
|
203
|
+
Select("tags").relationship("tags", "name").multiple(),
|
|
204
|
+
]),
|
|
205
|
+
Tab("Media").icon("image").schema([
|
|
206
|
+
FileUpload("images").image().multiple().max_size(2048).directory("products"),
|
|
207
|
+
]),
|
|
208
|
+
]),
|
|
209
|
+
Section("Status").schema([
|
|
210
|
+
Radio("status").options({"draft": "Draft", "published": "Published"}),
|
|
211
|
+
Toggle("is_featured"),
|
|
212
|
+
]),
|
|
213
|
+
])
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- **Wizard**: `Wizard([Step("Basic").schema([...]), Step("Review").schema([...])]).submit_label("Create")`
|
|
217
|
+
- **Repeater**: `Repeater("items").relationship("items", order_column="sort").table().schema([...])`, or leave out `.relationship()` to save a JSON list.
|
|
218
|
+
- **Dependent fields**: add `.live()` to the field others depend on. `after_state_updated(lambda state, set: set("slug", slugify(state)))` fills other fields.
|
|
219
|
+
- **Saving tricks**: `.dehydrated(False)` (don't save), `.dehydrate_state_using(fn)` (e.g. hash a password), `.save_relationships_using(fn)`, `.format_state_using(fn)`.
|
|
220
|
+
- **Rules**: `.rule(lambda value: value != "admin" or "That name is reserved.")`.
|
|
221
|
+
|
|
222
|
+
### Tables
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
table.columns([
|
|
226
|
+
TextColumn("name").avatar("photo").description(lambda record: record.email).searchable().sortable(),
|
|
227
|
+
TextColumn("status").badge().colors({"success": "paid", "danger": ["failed", "refunded"]}),
|
|
228
|
+
TextColumn("total").money("INR").summarize(Sum().money("INR")),
|
|
229
|
+
ImageColumn("logo").circular(),
|
|
230
|
+
IconColumn("is_active").boolean(),
|
|
231
|
+
ToggleColumn("is_visible"),
|
|
232
|
+
TextColumn("created_at").since().toggleable(hidden_by_default=True),
|
|
233
|
+
])
|
|
234
|
+
.filters([
|
|
235
|
+
SelectFilter("status").options(Status).multiple(),
|
|
236
|
+
TernaryFilter("is_active"),
|
|
237
|
+
DateFilter("created_at"),
|
|
238
|
+
Filter("big").label("Over ₹10,000").query(lambda query, model: query.where(model.total > 10000)),
|
|
239
|
+
TrashedFilter(),
|
|
240
|
+
])
|
|
241
|
+
.groups(["status", "customer.name"])
|
|
242
|
+
.actions([EditAction(), ActionGroup([ViewAction(), DeleteAction()])])
|
|
243
|
+
.bulk_actions([DeleteBulkAction(), BulkActionGroup([ExportBulkAction()])])
|
|
244
|
+
.header_actions([ImportAction(ProductImporter), ExportAction()])
|
|
245
|
+
.default_sort("created_at", "desc")
|
|
246
|
+
.paginated([10, 25, 50])
|
|
247
|
+
.row_index()
|
|
248
|
+
.striped()
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Table state (search, sort, filters, page) is kept in the URL, so you can bookmark and share it.
|
|
252
|
+
|
|
253
|
+
### Query builder filter
|
|
254
|
+
|
|
255
|
+
Let users build their own conditions in the filter panel. Rules in a group must all match (AND); groups are joined with OR.
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
from tungsten.tables import (QueryBuilder, TextConstraint, NumberConstraint, DateConstraint,
|
|
259
|
+
BooleanConstraint, SelectConstraint, RelationshipConstraint)
|
|
260
|
+
|
|
261
|
+
table.filters([
|
|
262
|
+
QueryBuilder().constraints([
|
|
263
|
+
TextConstraint("name"), # contains, starts with, equals, is blank...
|
|
264
|
+
NumberConstraint("price"), # =, >, <, between...
|
|
265
|
+
NumberConstraint("stock").integer(),
|
|
266
|
+
DateConstraint("created_at"), # on, before, after, between, in the last N days
|
|
267
|
+
BooleanConstraint("is_featured"),
|
|
268
|
+
SelectConstraint("status").options(Status), # is, is not, is any of...
|
|
269
|
+
TextConstraint("category.name").label("Category name"), # through a relationship
|
|
270
|
+
RelationshipConstraint("tags").selectable("name"), # has any / none / at least N / which ones
|
|
271
|
+
]),
|
|
272
|
+
])
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Example: *Price is greater than 3000 and Tags has at least 1* — **or** — *Featured is true*. The rules live in the URL like other filters.
|
|
276
|
+
|
|
277
|
+
### Actions
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
Action("ship").icon("truck").color("info")
|
|
281
|
+
.visible(lambda record: record.status == "paid")
|
|
282
|
+
.requires_confirmation()
|
|
283
|
+
.action(lambda record, db: (setattr(record, "status", "shipped"), db.commit()))
|
|
284
|
+
.success_notification_title("Order shipped")
|
|
285
|
+
|
|
286
|
+
Action("email").form([TextInput("subject").required(), Textarea("body")])
|
|
287
|
+
.action(lambda record, data: send_mail(record.email, **data))
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Use them in table rows, bulk (`BulkAction`, gets `records`), table headers, or page headers. Raise `Halt` to keep the popup open.
|
|
291
|
+
|
|
292
|
+
### Infolists (view pages)
|
|
293
|
+
|
|
294
|
+
Give a resource an `infolist()` and its view page shows formatted values instead of a disabled form. Entries take every `TextColumn` option:
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
@classmethod
|
|
298
|
+
def infolist(cls, infolist):
|
|
299
|
+
return infolist.columns(3).schema([
|
|
300
|
+
Section("Product").column_span(2).schema([
|
|
301
|
+
TextEntry("name").weight("semibold"),
|
|
302
|
+
TextEntry("category.name").badge(),
|
|
303
|
+
TextEntry("description").html().column_span("full"),
|
|
304
|
+
ImageEntry("images").stacked(),
|
|
305
|
+
]),
|
|
306
|
+
Section("Pricing").schema([
|
|
307
|
+
TextEntry("price").money("INR"),
|
|
308
|
+
IconEntry("is_featured").boolean(),
|
|
309
|
+
KeyValueEntry("attributes"),
|
|
310
|
+
TextEntry("created_at").datetime().inline_label(),
|
|
311
|
+
]),
|
|
312
|
+
])
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`RepeatableEntry("items").schema([...])` shows each related row (or JSON list item) with its own entries. Relation managers can have an `infolist()` too, used by their View popup.
|
|
316
|
+
|
|
317
|
+
### More table features
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
table.tabs([ # tabs above the table, with counts
|
|
321
|
+
ListTab("all").badge(),
|
|
322
|
+
ListTab("active").badge(color="success").query(lambda query, model: query.where(model.is_active)),
|
|
323
|
+
])
|
|
324
|
+
table.reorderable("sort") # a "Reorder" button: drag rows, order saved to `sort`
|
|
325
|
+
TextInputColumn("stock").integer().configure(lambda field: field.min_value(0)) # edit in the table
|
|
326
|
+
SelectColumn("status").options({"draft": "Draft", "published": "Published"})
|
|
327
|
+
CheckboxColumn("is_featured")
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Inline-edited values are checked with the same rules as form fields; a bad value shows an error toast and isn't saved.
|
|
331
|
+
|
|
332
|
+
### Builder and ToggleButtons
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
Builder("content").blocks([
|
|
336
|
+
Block("heading").icon("heading").schema([TextInput("text").required()]),
|
|
337
|
+
Block("paragraph").icon("pilcrow").schema([RichEditor("body")]),
|
|
338
|
+
Block("image").icon("image").schema([FileUpload("image").image()]),
|
|
339
|
+
]) # saved as [{"type": "heading", "data": {...}}, ...]
|
|
340
|
+
|
|
341
|
+
ToggleButtons("status").options({"draft": "Draft", "published": "Published"})
|
|
342
|
+
.icons({"draft": "pencil", "published": "circle-check"})
|
|
343
|
+
.colors({"draft": "gray", "published": "success"})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Relation managers
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
class OrdersRelationManager(RelationManager):
|
|
350
|
+
relationship = "orders"
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
def form(cls, form): ...
|
|
354
|
+
@classmethod
|
|
355
|
+
def table(cls, table):
|
|
356
|
+
return table.columns([...]).header_actions([CreateAction()]).actions([EditAction(), DeleteAction()])
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
For many-to-many use `AttachAction()`, `DetachAction()` and `DetachBulkAction()`.
|
|
360
|
+
|
|
361
|
+
### Widgets
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
class Stats(StatsOverviewWidget):
|
|
365
|
+
@classmethod
|
|
366
|
+
def stats(cls, db):
|
|
367
|
+
return [Stat("Users", "1,248").icon("users").trend("12%", "up").chart([3, 5, 4, 8, 9])]
|
|
368
|
+
|
|
369
|
+
class Revenue(ChartWidget):
|
|
370
|
+
heading = "Revenue"
|
|
371
|
+
type = "bar"
|
|
372
|
+
filters = {"12": "Last 12 months", "3": "Last 3 months"}
|
|
373
|
+
|
|
374
|
+
@classmethod
|
|
375
|
+
def data(cls, db, filter):
|
|
376
|
+
return {"labels": [...], "datasets": [{"label": "Revenue", "data": [...], "color": "primary"}]}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Also `TableWidget` (with `model`, `query()`, `table()`), `ProgressListWidget` and `AccountWidget`. Set `column_span`, `sort`, `lazy` and `polling_interval` on any widget.
|
|
380
|
+
|
|
381
|
+
**Dashboard filters.** Give your dashboard a `filters_form`; the values show in the header and reach every widget as `filters`:
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
class MyDashboard(Dashboard):
|
|
385
|
+
@classmethod
|
|
386
|
+
def filters_form(cls, form):
|
|
387
|
+
return form.schema([Select("period").options({"7": "Last 7 days", "30": "Last 30 days"}).default("30")])
|
|
388
|
+
|
|
389
|
+
class Stats(StatsOverviewWidget):
|
|
390
|
+
@classmethod
|
|
391
|
+
def stats(cls, db, filters):
|
|
392
|
+
days = int(filters["period"])
|
|
393
|
+
...
|
|
394
|
+
|
|
395
|
+
Panel(..., dashboard=MyDashboard)
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Roles & permissions
|
|
399
|
+
|
|
400
|
+
`panel.rbac()` adds a **Roles & Permissions** screen. Permissions are named `<resource-slug>.<ability>`: `view_any`, `view`, `create`, `update`, `delete`, `delete_any`, `restore`, `force_delete`… Wildcards work (`products.*`, `*`). Give users roles with `RolesField()` in your user form, `auth.assign_role(db, user, "Admin")`, or the CLI.
|
|
401
|
+
|
|
402
|
+
You can also use a `policy` on a resource, `Auth(gate=lambda user, permission, record: ...)`, or `Auth(can_access=lambda user: user.is_staff)` to decide who can sign in.
|
|
403
|
+
|
|
404
|
+
### Sign-up and two-factor login
|
|
405
|
+
|
|
406
|
+
```python
|
|
407
|
+
Auth(User,
|
|
408
|
+
registration=True, # adds a "Create an account" page
|
|
409
|
+
on_register=lambda user, db: ...,
|
|
410
|
+
two_factor=True, # users can turn on 2FA from their profile
|
|
411
|
+
two_factor_required=False) # True forces everyone to set it up
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Two-factor uses 6-digit codes from any authenticator app (Google Authenticator, Microsoft Authenticator, 1Password…). Users scan a QR code, confirm one code, and get 8 one-time recovery codes. Each code works only once.
|
|
415
|
+
|
|
416
|
+
### Email verification
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
Auth(User, email_verification=True) # needs a nullable datetime column: email_verified_at
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
New users get an email with a signed link (valid 60 minutes, `verification_minutes=` to change). Until they click it they only see a "Verify your email" page with a **Resend** button (once a minute). Changing the email on the profile page asks for verification again, and old links stop working. Emails go through `Auth(mailer=...)`, which receives `to`, `subject`, `body` (and `url`, `user`, `kind` if it asks for them).
|
|
423
|
+
|
|
424
|
+
### Translations
|
|
425
|
+
|
|
426
|
+
```python
|
|
427
|
+
Panel(...,
|
|
428
|
+
locale="en", # default language
|
|
429
|
+
locales=["en", "hi"], # languages users can pick (switcher appears when more than one)
|
|
430
|
+
lang_dirs=["lang"]) # your own lang/<code>.json files
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Text is looked up by its English wording. Tungsten ships **Hindi** (`hi`). For your own labels, add a JSON file:
|
|
434
|
+
|
|
435
|
+
```json
|
|
436
|
+
// lang/hi.json
|
|
437
|
+
{"Products": "उत्पाद", "Low stock only": "सिर्फ़ कम स्टॉक", "Welcome, :name": "स्वागत है, :name"}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Labels, headings, buttons, options, notifications and validation messages are translated automatically. In your own code use `__()`:
|
|
441
|
+
|
|
442
|
+
```python
|
|
443
|
+
from tungsten import __
|
|
444
|
+
Notification(__("Order shipped")).send(ctx)
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
`tungsten lang:extract hi --path app` collects the text in your code into `lang/hi.json`, ready to fill in. The language comes from the user's pick, then the browser's language, then `locale`. Right-to-left languages (Arabic, Hebrew, Urdu, Persian) set `dir="rtl"`.
|
|
448
|
+
|
|
449
|
+
### Async database
|
|
450
|
+
|
|
451
|
+
Pass an async engine and everything else stays the same:
|
|
452
|
+
|
|
453
|
+
```python
|
|
454
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
455
|
+
|
|
456
|
+
panel = Panel(engine=create_async_engine("postgresql+asyncpg://..."), ...)
|
|
457
|
+
await panel.acreate_tables() # or panel.create_tables() outside an event loop
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Handlers run on the event loop through SQLAlchemy's `AsyncSession.run_sync`, so your closures still get a normal `db` session and lazy loading keeps working. Password hashing and file uploads run in a worker thread so they don't block the loop. Any closure can also be `async def` — Tungsten awaits it. For scripts, `panel.with_session(lambda db: ...)` works with both engine types.
|
|
461
|
+
|
|
462
|
+
### Notifications
|
|
463
|
+
|
|
464
|
+
```python
|
|
465
|
+
Notification("Saved").body("The product was updated.").success().send(ctx) # toast
|
|
466
|
+
Notification("New order").body("#ORD-12").action("View", url).send_to_database(admins, db) # bell
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Custom pages
|
|
470
|
+
|
|
471
|
+
```python
|
|
472
|
+
class Settings(Page):
|
|
473
|
+
icon = "settings"
|
|
474
|
+
navigation_group = "Settings"
|
|
475
|
+
permission = "page.settings"
|
|
476
|
+
|
|
477
|
+
@classmethod
|
|
478
|
+
def form(cls, form): return form.schema([TextInput("site_name").required()])
|
|
479
|
+
@classmethod
|
|
480
|
+
def mount(cls, ctx): return load_settings()
|
|
481
|
+
@classmethod
|
|
482
|
+
def save(cls, ctx, data): store_settings(data)
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Pages can also return `content()` HTML, use a `content_template`, and show `widgets`.
|
|
486
|
+
|
|
487
|
+
### Import & export
|
|
488
|
+
|
|
489
|
+
`ExportAction()` downloads the current table (with its search and filters) as CSV or Excel. `ExportBulkAction()` exports the selected rows. For import, describe the columns:
|
|
490
|
+
|
|
491
|
+
```python
|
|
492
|
+
class ProductImporter(Importer):
|
|
493
|
+
model = Product
|
|
494
|
+
unique_by = "sku" # update rows that already exist
|
|
495
|
+
columns = [
|
|
496
|
+
ImportColumn("name").required(),
|
|
497
|
+
ImportColumn("price").numeric(),
|
|
498
|
+
ImportColumn("category").relationship("category", "name"),
|
|
499
|
+
]
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Rows that fail are skipped and offered as a "failed rows" CSV download.
|
|
503
|
+
|
|
504
|
+
### Multi-tenancy
|
|
505
|
+
|
|
506
|
+
```python
|
|
507
|
+
Panel(..., tenancy=Tenancy(Team, ownership="team_id", tenants=lambda user: user.teams))
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Models with a `team_id` column are filtered to the current team, and new records get it set automatically. Users switch teams from the sidebar.
|
|
511
|
+
|
|
512
|
+
### Plugins & render hooks
|
|
513
|
+
|
|
514
|
+
```python
|
|
515
|
+
class BlogPlugin(Plugin):
|
|
516
|
+
id = "blog"
|
|
517
|
+
def register(self, panel): panel.resources([PostResource])
|
|
518
|
+
def boot(self, panel): panel.render_hook("sidebar.footer", lambda: Markup("<p>Blog</p>"))
|
|
519
|
+
|
|
520
|
+
panel.plugin(BlogPlugin())
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Hook names: `head.end`, `body.start`, `body.end`, `sidebar.nav.start`, `sidebar.nav.end`, `sidebar.footer`, `topbar.start`, `topbar.end`, `content.start`, `content.end`, `user-menu.items`, `auth.login.form.after`, `resource.list.before-table`.
|
|
524
|
+
|
|
525
|
+
### Layout & UX options
|
|
526
|
+
|
|
527
|
+
```python
|
|
528
|
+
Panel(...,
|
|
529
|
+
spa=True, # move between pages without full reloads
|
|
530
|
+
unsaved_changes_alerts=True, # warn before leaving a changed form (default on)
|
|
531
|
+
sidebar_collapsible=True) # desktop sidebar can collapse to icons (default on)
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Save buttons on record pages respond to **Ctrl/⌘+S**. Give any action a shortcut with `.keyboard_shortcut("mod+e")`. In global search, use the arrow keys to move through results.
|
|
535
|
+
|
|
536
|
+
Override any template by passing `template_dirs=[...]` and putting your own file at the same path (for example `tungsten/components/brand.html`).
|
|
537
|
+
|
|
538
|
+
### CLI
|
|
539
|
+
|
|
540
|
+
```bash
|
|
541
|
+
tungsten init # admin/panel.py
|
|
542
|
+
tungsten make:resource Product -m app.models:Product -g # -g builds fields/columns from the model
|
|
543
|
+
tungsten make:resource Tag --simple
|
|
544
|
+
tungsten make:relation-manager Customer orders
|
|
545
|
+
tungsten make:page Settings --form
|
|
546
|
+
tungsten make:widget Sales --type chart # stats, chart, table, progress
|
|
547
|
+
tungsten make:plugin Blog
|
|
548
|
+
tungsten make:user --panel app.admin:panel --role "Super Admin"
|
|
549
|
+
tungsten lang:extract hi --path app # collect text to translate into lang/hi.json
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## Security notes
|
|
555
|
+
|
|
556
|
+
- Every POST checks a CSRF token. The session cookie is signed, `SameSite=Lax` (set `https_only_cookies=True` in production).
|
|
557
|
+
- Login is rate-limited per IP and email.
|
|
558
|
+
- Rich text is cleaned to a safe allow-list of tags. Uploads of HTML, SVG and scripts are refused, and stored files are served with `nosniff` and a sandbox CSP.
|
|
559
|
+
- CSV exports escape spreadsheet formulas.
|
|
560
|
+
|
|
561
|
+
## Development
|
|
562
|
+
|
|
563
|
+
```bash
|
|
564
|
+
pip install -e ".[dev]"
|
|
565
|
+
pytest # 91 tests
|
|
566
|
+
TUNGSTEN_TEST_ASYNC=1 pytest # the same tests on an async engine (aiosqlite)
|
|
567
|
+
|
|
568
|
+
# rebuild CSS/JS assets after changing templates or classes
|
|
569
|
+
cd frontend && npm install && npm run build
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
The built assets (Tailwind CSS, HTMX, Alpine.js, Chart.js, Trix, Tom Select, SortableJS, a QR code generator and Lucide icons) ship inside the package. Nothing loads from a CDN, except the optional Google font (`Panel(font=None)` turns it off).
|
|
573
|
+
|
|
574
|
+
## Roadmap
|
|
575
|
+
|
|
576
|
+
- Impersonation (sign in as another user)
|
|
577
|
+
- More built-in languages
|
|
578
|
+
|
|
579
|
+
## License
|
|
580
|
+
|
|
581
|
+
MIT
|