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.
Files changed (144) hide show
  1. tungsten_admin-0.1.0/.gitignore +12 -0
  2. tungsten_admin-0.1.0/LICENSE +21 -0
  3. tungsten_admin-0.1.0/PKG-INFO +581 -0
  4. tungsten_admin-0.1.0/README.md +548 -0
  5. tungsten_admin-0.1.0/pyproject.toml +52 -0
  6. tungsten_admin-0.1.0/src/tungsten/__init__.py +53 -0
  7. tungsten_admin-0.1.0/src/tungsten/actions/__init__.py +25 -0
  8. tungsten_admin-0.1.0/src/tungsten/actions/action.py +432 -0
  9. tungsten_admin-0.1.0/src/tungsten/actions/prebuilt.py +396 -0
  10. tungsten_admin-0.1.0/src/tungsten/auth/__init__.py +333 -0
  11. tungsten_admin-0.1.0/src/tungsten/auth/profile.py +73 -0
  12. tungsten_admin-0.1.0/src/tungsten/auth/rbac.py +138 -0
  13. tungsten_admin-0.1.0/src/tungsten/auth/two_factor.py +147 -0
  14. tungsten_admin-0.1.0/src/tungsten/auth/verification.py +91 -0
  15. tungsten_admin-0.1.0/src/tungsten/cli/__init__.py +352 -0
  16. tungsten_admin-0.1.0/src/tungsten/cli/stubs.py +251 -0
  17. tungsten_admin-0.1.0/src/tungsten/context.py +112 -0
  18. tungsten_admin-0.1.0/src/tungsten/forms/__init__.py +36 -0
  19. tungsten_admin-0.1.0/src/tungsten/forms/base.py +57 -0
  20. tungsten_admin-0.1.0/src/tungsten/forms/fields.py +1960 -0
  21. tungsten_admin-0.1.0/src/tungsten/forms/form.py +292 -0
  22. tungsten_admin-0.1.0/src/tungsten/forms/layout.py +341 -0
  23. tungsten_admin-0.1.0/src/tungsten/hosts.py +343 -0
  24. tungsten_admin-0.1.0/src/tungsten/i18n.py +284 -0
  25. tungsten_admin-0.1.0/src/tungsten/importexport.py +413 -0
  26. tungsten_admin-0.1.0/src/tungsten/infolists.py +263 -0
  27. tungsten_admin-0.1.0/src/tungsten/lang/hi.json +362 -0
  28. tungsten_admin-0.1.0/src/tungsten/models.py +97 -0
  29. tungsten_admin-0.1.0/src/tungsten/navigation.py +37 -0
  30. tungsten_admin-0.1.0/src/tungsten/notifications.py +120 -0
  31. tungsten_admin-0.1.0/src/tungsten/pages.py +222 -0
  32. tungsten_admin-0.1.0/src/tungsten/panel.py +494 -0
  33. tungsten_admin-0.1.0/src/tungsten/plugins.py +35 -0
  34. tungsten_admin-0.1.0/src/tungsten/rendering.py +71 -0
  35. tungsten_admin-0.1.0/src/tungsten/resources/__init__.py +4 -0
  36. tungsten_admin-0.1.0/src/tungsten/resources/relation_manager.py +88 -0
  37. tungsten_admin-0.1.0/src/tungsten/resources/resource.py +232 -0
  38. tungsten_admin-0.1.0/src/tungsten/routes.py +1305 -0
  39. tungsten_admin-0.1.0/src/tungsten/static/tungsten/favicon.svg +1 -0
  40. tungsten_admin-0.1.0/src/tungsten/static/tungsten/tungsten.css +1 -0
  41. tungsten_admin-0.1.0/src/tungsten/static/tungsten/tungsten.js +330 -0
  42. tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/alpine.min.js +21 -0
  43. tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/chart.umd.min.js +14 -0
  44. tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/htmx.min.js +1 -0
  45. tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/qrcode.js +2297 -0
  46. tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/sortable.min.js +2 -0
  47. tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/tom-select.complete.min.js +454 -0
  48. tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/tom-select.css +413 -0
  49. tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/trix.css +470 -0
  50. tungsten_admin-0.1.0/src/tungsten/static/tungsten/vendor/trix.umd.min.js +7 -0
  51. tungsten_admin-0.1.0/src/tungsten/storage.py +69 -0
  52. tungsten_admin-0.1.0/src/tungsten/support/__init__.py +0 -0
  53. tungsten_admin-0.1.0/src/tungsten/support/aio.py +71 -0
  54. tungsten_admin-0.1.0/src/tungsten/support/colors.py +172 -0
  55. tungsten_admin-0.1.0/src/tungsten/support/component.py +57 -0
  56. tungsten_admin-0.1.0/src/tungsten/support/evaluate.py +52 -0
  57. tungsten_admin-0.1.0/src/tungsten/support/html.py +85 -0
  58. tungsten_admin-0.1.0/src/tungsten/support/icons.json +1 -0
  59. tungsten_admin-0.1.0/src/tungsten/support/icons.py +69 -0
  60. tungsten_admin-0.1.0/src/tungsten/support/palettes.py +26 -0
  61. tungsten_admin-0.1.0/src/tungsten/support/state.py +99 -0
  62. tungsten_admin-0.1.0/src/tungsten/tables/__init__.py +38 -0
  63. tungsten_admin-0.1.0/src/tungsten/tables/columns.py +849 -0
  64. tungsten_admin-0.1.0/src/tungsten/tables/filters.py +365 -0
  65. tungsten_admin-0.1.0/src/tungsten/tables/query_builder.py +617 -0
  66. tungsten_admin-0.1.0/src/tungsten/tables/summarizers.py +81 -0
  67. tungsten_admin-0.1.0/src/tungsten/tables/table.py +826 -0
  68. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/actions/button.html +21 -0
  69. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/actions/group.html +14 -0
  70. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/actions/modal.html +47 -0
  71. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/forgot-password.html +19 -0
  72. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/login.html +21 -0
  73. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/register.html +19 -0
  74. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/reset-password.html +17 -0
  75. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/two-factor-challenge.html +15 -0
  76. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/two-factor-setup.html +69 -0
  77. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/auth/verify-email.html +36 -0
  78. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/brand.html +10 -0
  79. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/global-search.html +13 -0
  80. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/notification-badge.html +1 -0
  81. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/notification-bell.html +11 -0
  82. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/notifications.html +32 -0
  83. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/page-filters.html +6 -0
  84. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/page-header.html +14 -0
  85. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/search-results.html +34 -0
  86. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/toasts.html +21 -0
  87. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/ui.html +182 -0
  88. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/components/widgets.html +16 -0
  89. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/checkbox-list.html +26 -0
  90. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/checkbox.html +10 -0
  91. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/color-picker.html +12 -0
  92. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/date-picker.html +8 -0
  93. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/file-upload.html +50 -0
  94. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/hidden.html +1 -0
  95. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/key-value.html +32 -0
  96. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/permissions.html +34 -0
  97. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/placeholder.html +4 -0
  98. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/radio.html +16 -0
  99. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/repeater.html +75 -0
  100. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/rich-editor.html +15 -0
  101. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/select.html +26 -0
  102. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/tags-input.html +30 -0
  103. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/text-input.html +33 -0
  104. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/textarea.html +10 -0
  105. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/toggle-buttons.html +28 -0
  106. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/fields/toggle.html +23 -0
  107. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/form.html +7 -0
  108. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/grid.html +6 -0
  109. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/layouts/fieldset.html +4 -0
  110. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/layouts/group.html +1 -0
  111. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/layouts/section.html +26 -0
  112. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/layouts/tabs.html +19 -0
  113. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/layouts/wizard.html +41 -0
  114. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/forms/macros.html +46 -0
  115. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/infolists/entry.html +9 -0
  116. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/infolists/infolist.html +3 -0
  117. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/infolists/key-value-entry.html +15 -0
  118. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/infolists/repeatable-entry.html +8 -0
  119. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/layout/app.html +167 -0
  120. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/layout/auth.html +36 -0
  121. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/layout/base.html +53 -0
  122. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/pages/dashboard.html +13 -0
  123. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/pages/error.html +14 -0
  124. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/pages/page.html +19 -0
  125. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/pages/profile.html +23 -0
  126. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/resources/list.html +8 -0
  127. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/resources/record.html +54 -0
  128. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/checkbox.html +5 -0
  129. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/color.html +3 -0
  130. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/icon.html +3 -0
  131. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/image.html +12 -0
  132. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/select.html +8 -0
  133. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/text-input.html +6 -0
  134. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/text.html +33 -0
  135. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/columns/toggle.html +11 -0
  136. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/query-builder.html +74 -0
  137. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/tables/table.html +253 -0
  138. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/widgets/account.html +9 -0
  139. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/widgets/chart.html +37 -0
  140. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/widgets/progress.html +21 -0
  141. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/widgets/stats.html +7 -0
  142. tungsten_admin-0.1.0/src/tungsten/templates/tungsten/widgets/table.html +14 -0
  143. tungsten_admin-0.1.0/src/tungsten/tenancy.py +102 -0
  144. tungsten_admin-0.1.0/src/tungsten/widgets.py +269 -0
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ frontend/node_modules/
9
+ storage/
10
+ *.db
11
+ *.sqlite3
12
+ .DS_Store
@@ -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