fyro 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.
- fyro-0.1.0/LICENSE +21 -0
- fyro-0.1.0/PKG-INFO +101 -0
- fyro-0.1.0/README.md +64 -0
- fyro-0.1.0/pyproject.toml +78 -0
- fyro-0.1.0/pyproject.toml.orig +70 -0
- fyro-0.1.0/src/fyro/__init__.py +5 -0
- fyro-0.1.0/src/fyro/admin/__init__.py +50 -0
- fyro-0.1.0/src/fyro/admin/forms.py +137 -0
- fyro-0.1.0/src/fyro/admin/routes.py +435 -0
- fyro-0.1.0/src/fyro/admin/static/admin.css +147 -0
- fyro-0.1.0/src/fyro/admin/static/htmx.LICENSE +16 -0
- fyro-0.1.0/src/fyro/admin/static/htmx.min.js +1 -0
- fyro-0.1.0/src/fyro/admin/static/jetbrains-mono.LICENSE +97 -0
- fyro-0.1.0/src/fyro/admin/static/jetbrains-mono.woff2 +0 -0
- fyro-0.1.0/src/fyro/admin/templates/base.html +33 -0
- fyro-0.1.0/src/fyro/admin/templates/confirm_delete.html +16 -0
- fyro-0.1.0/src/fyro/admin/templates/forbidden.html +9 -0
- fyro-0.1.0/src/fyro/admin/templates/form.html +37 -0
- fyro-0.1.0/src/fyro/admin/templates/index.html +12 -0
- fyro-0.1.0/src/fyro/admin/templates/list.html +64 -0
- fyro-0.1.0/src/fyro/admin/templates/login.html +12 -0
- fyro-0.1.0/src/fyro/app.py +102 -0
- fyro-0.1.0/src/fyro/apps.py +100 -0
- fyro-0.1.0/src/fyro/auth/__init__.py +6 -0
- fyro-0.1.0/src/fyro/auth/models.py +35 -0
- fyro-0.1.0/src/fyro/auth/passwords.py +20 -0
- fyro-0.1.0/src/fyro/auth/routes.py +45 -0
- fyro-0.1.0/src/fyro/auth/sessions.py +130 -0
- fyro-0.1.0/src/fyro/cli.py +395 -0
- fyro-0.1.0/src/fyro/conf.py +121 -0
- fyro-0.1.0/src/fyro/crud.py +193 -0
- fyro-0.1.0/src/fyro/db/__init__.py +12 -0
- fyro-0.1.0/src/fyro/db/engine.py +47 -0
- fyro-0.1.0/src/fyro/db/session.py +81 -0
- fyro-0.1.0/src/fyro/migrations/__init__.py +279 -0
- fyro-0.1.0/src/fyro/migrations/template/env.py +34 -0
- fyro-0.1.0/src/fyro/migrations/template/script.py.mako +28 -0
- fyro-0.1.0/src/fyro/models/__init__.py +4 -0
- fyro-0.1.0/src/fyro/models/base.py +281 -0
- fyro-0.1.0/src/fyro/models/fields.py +62 -0
- fyro-0.1.0/src/fyro/models/manager.py +128 -0
- fyro-0.1.0/src/fyro/models/schemas.py +57 -0
fyro-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chinmay Talnikar
|
|
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.
|
fyro-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fyro
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Django-feeling web framework on top of FastAPI: one model class gives you a table, an API, migrations, auth and an admin.
|
|
5
|
+
Keywords: fastapi,django,web framework,orm,admin,postgres,sqlalchemy
|
|
6
|
+
Author: Chinmay Talnikar
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
20
|
+
Requires-Dist: alembic>=1.20.1
|
|
21
|
+
Requires-Dist: asyncpg>=0.32.0
|
|
22
|
+
Requires-Dist: fastapi>=0.159.1
|
|
23
|
+
Requires-Dist: jinja2>=3.1.6
|
|
24
|
+
Requires-Dist: pwdlib[argon2]>=0.3.1
|
|
25
|
+
Requires-Dist: pydantic>=2.14.1
|
|
26
|
+
Requires-Dist: pydantic-settings>=2.16.1
|
|
27
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.1.6
|
|
28
|
+
Requires-Dist: typer>=0.27.3
|
|
29
|
+
Requires-Dist: uvicorn>=0.54.0
|
|
30
|
+
Requires-Python: >=3.12
|
|
31
|
+
Project-URL: Homepage, https://github.com/Carnage725/fyro
|
|
32
|
+
Project-URL: Documentation, https://carnage725.github.io/fyro/
|
|
33
|
+
Project-URL: Source, https://github.com/Carnage725/fyro
|
|
34
|
+
Project-URL: Issues, https://github.com/Carnage725/fyro/issues
|
|
35
|
+
Project-URL: Changelog, https://github.com/Carnage725/fyro/blob/main/CHANGELOG.md
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# fyro
|
|
39
|
+
|
|
40
|
+
**Fast, Yet Ridiculously Opinionated.** A web framework on top of FastAPI that feels like Django.
|
|
41
|
+
|
|
42
|
+
FastAPI is the engine. fyro adds what a project needs around it, with one obvious place for everything: one model class gives you a Postgres table, `Create`/`Read`/`Update` schemas, a REST API with docs, migrations and an admin page. Database sessions, a command line, users, login and permissions are built in.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
# blog/models.py
|
|
46
|
+
from fyro import Field, Model
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Post(Model):
|
|
50
|
+
title: str = Field(max_length=200)
|
|
51
|
+
body: str
|
|
52
|
+
secret: str = Field(write_only=True)
|
|
53
|
+
published: bool = False
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That class is the table `blog_post`, the API at `/api/blog/post` (list with filters, search, ordering and paging; get; create; patch; delete) and the admin page at `/admin/blog/post`. Queries need no session code:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
posts = await Post.objects.filter(published=True).order_by("-id").limit(10).all()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quickstart
|
|
63
|
+
|
|
64
|
+
Needs Python 3.12+, Docker (for Postgres) and [uv](https://docs.astral.sh/uv/) or pip.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv venv && source .venv/bin/activate
|
|
68
|
+
uv pip install fyro
|
|
69
|
+
|
|
70
|
+
fyro new mysite && cd mysite
|
|
71
|
+
docker compose up -d --wait
|
|
72
|
+
python manage.py startapp blog
|
|
73
|
+
# write blog/models.py, then in mysite/settings.py:
|
|
74
|
+
# installed_apps: list[str] = ["fyro.auth", "blog"]
|
|
75
|
+
python manage.py makemigrations
|
|
76
|
+
python manage.py migrate
|
|
77
|
+
python manage.py createsuperuser
|
|
78
|
+
python manage.py runserver # http://127.0.0.1:8000/docs and /admin
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The full walkthrough and the guides are in [`docs/`](docs/index.md): settings, models, querying, the automatic API, migrations, the command line, auth and permissions, the admin, and security notes.
|
|
82
|
+
|
|
83
|
+
## Status
|
|
84
|
+
|
|
85
|
+
**v0.1, alpha.** PostgreSQL only. Relations between models come in v0.2; see [what v0.1 does not do](docs/limitations.md). The plan and the history of every design decision are in [`PLAN.md`](PLAN.md) and [`BUILD_PROMPT.md`](BUILD_PROMPT.md).
|
|
86
|
+
|
|
87
|
+
## Development
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv sync # install dependencies
|
|
91
|
+
docker compose up -d # Postgres 16 on localhost:5432 (user, password, db: fyro)
|
|
92
|
+
uv run pytest # tests, in random order (pytest-randomly)
|
|
93
|
+
uv run ruff check # lint
|
|
94
|
+
uv run --only-group docs zensical serve # docs at http://127.0.0.1:8000
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
How tests are written and attacked is described in [`test.md`](test.md).
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
[MIT](LICENSE). The admin bundles [htmx](https://htmx.org) (0BSD) and [JetBrains Mono](https://www.jetbrains.com/lp/mono/) (OFL-1.1); their licenses are in `src/fyro/admin/static/`.
|
fyro-0.1.0/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# fyro
|
|
2
|
+
|
|
3
|
+
**Fast, Yet Ridiculously Opinionated.** A web framework on top of FastAPI that feels like Django.
|
|
4
|
+
|
|
5
|
+
FastAPI is the engine. fyro adds what a project needs around it, with one obvious place for everything: one model class gives you a Postgres table, `Create`/`Read`/`Update` schemas, a REST API with docs, migrations and an admin page. Database sessions, a command line, users, login and permissions are built in.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
# blog/models.py
|
|
9
|
+
from fyro import Field, Model
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Post(Model):
|
|
13
|
+
title: str = Field(max_length=200)
|
|
14
|
+
body: str
|
|
15
|
+
secret: str = Field(write_only=True)
|
|
16
|
+
published: bool = False
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That class is the table `blog_post`, the API at `/api/blog/post` (list with filters, search, ordering and paging; get; create; patch; delete) and the admin page at `/admin/blog/post`. Queries need no session code:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
posts = await Post.objects.filter(published=True).order_by("-id").limit(10).all()
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
Needs Python 3.12+, Docker (for Postgres) and [uv](https://docs.astral.sh/uv/) or pip.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv venv && source .venv/bin/activate
|
|
31
|
+
uv pip install fyro
|
|
32
|
+
|
|
33
|
+
fyro new mysite && cd mysite
|
|
34
|
+
docker compose up -d --wait
|
|
35
|
+
python manage.py startapp blog
|
|
36
|
+
# write blog/models.py, then in mysite/settings.py:
|
|
37
|
+
# installed_apps: list[str] = ["fyro.auth", "blog"]
|
|
38
|
+
python manage.py makemigrations
|
|
39
|
+
python manage.py migrate
|
|
40
|
+
python manage.py createsuperuser
|
|
41
|
+
python manage.py runserver # http://127.0.0.1:8000/docs and /admin
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The full walkthrough and the guides are in [`docs/`](docs/index.md): settings, models, querying, the automatic API, migrations, the command line, auth and permissions, the admin, and security notes.
|
|
45
|
+
|
|
46
|
+
## Status
|
|
47
|
+
|
|
48
|
+
**v0.1, alpha.** PostgreSQL only. Relations between models come in v0.2; see [what v0.1 does not do](docs/limitations.md). The plan and the history of every design decision are in [`PLAN.md`](PLAN.md) and [`BUILD_PROMPT.md`](BUILD_PROMPT.md).
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv sync # install dependencies
|
|
54
|
+
docker compose up -d # Postgres 16 on localhost:5432 (user, password, db: fyro)
|
|
55
|
+
uv run pytest # tests, in random order (pytest-randomly)
|
|
56
|
+
uv run ruff check # lint
|
|
57
|
+
uv run --only-group docs zensical serve # docs at http://127.0.0.1:8000
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
How tests are written and attacked is described in [`test.md`](test.md).
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
[MIT](LICENSE). The admin bundles [htmx](https://htmx.org) (0BSD) and [JetBrains Mono](https://www.jetbrains.com/lp/mono/) (OFL-1.1); their licenses are in `src/fyro/admin/static/`.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fyro"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Django-feeling web framework on top of FastAPI: one model class gives you a table, an API, migrations, auth and an admin."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
keywords = [
|
|
10
|
+
"fastapi",
|
|
11
|
+
"django",
|
|
12
|
+
"web framework",
|
|
13
|
+
"orm",
|
|
14
|
+
"admin",
|
|
15
|
+
"postgres",
|
|
16
|
+
"sqlalchemy",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Framework :: FastAPI",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Database",
|
|
28
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
29
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"alembic>=1.20.1",
|
|
33
|
+
"asyncpg>=0.32.0",
|
|
34
|
+
"fastapi>=0.159.1",
|
|
35
|
+
"jinja2>=3.1.6",
|
|
36
|
+
"pwdlib[argon2]>=0.3.1",
|
|
37
|
+
"pydantic>=2.14.1",
|
|
38
|
+
"pydantic-settings>=2.16.1",
|
|
39
|
+
"sqlalchemy[asyncio]>=2.1.6",
|
|
40
|
+
"typer>=0.27.3",
|
|
41
|
+
"uvicorn>=0.54.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[[project.authors]]
|
|
45
|
+
name = "Chinmay Talnikar"
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/Carnage725/fyro"
|
|
49
|
+
Documentation = "https://carnage725.github.io/fyro/"
|
|
50
|
+
Source = "https://github.com/Carnage725/fyro"
|
|
51
|
+
Issues = "https://github.com/Carnage725/fyro/issues"
|
|
52
|
+
Changelog = "https://github.com/Carnage725/fyro/blob/main/CHANGELOG.md"
|
|
53
|
+
|
|
54
|
+
[project.scripts]
|
|
55
|
+
fyro = "fyro.cli:main"
|
|
56
|
+
|
|
57
|
+
[build-system]
|
|
58
|
+
requires = ["uv_build>=0.10.11,<0.11"]
|
|
59
|
+
build-backend = "uv_build"
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
testpaths = ["tests"]
|
|
63
|
+
asyncio_mode = "auto"
|
|
64
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
65
|
+
markers = ["slow: end-to-end tests that start real processes"]
|
|
66
|
+
timeout = 120
|
|
67
|
+
|
|
68
|
+
[dependency-groups]
|
|
69
|
+
dev = [
|
|
70
|
+
"httpx>=0.28.1",
|
|
71
|
+
"hypothesis>=6.170.5",
|
|
72
|
+
"pytest>=9.1.1",
|
|
73
|
+
"pytest-asyncio>=1.4.0",
|
|
74
|
+
"pytest-randomly>=5.0.0",
|
|
75
|
+
"pytest-timeout>=2.4.0",
|
|
76
|
+
"ruff>=0.17.1",
|
|
77
|
+
]
|
|
78
|
+
docs = ["zensical==0.0.69"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fyro"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Django-feeling web framework on top of FastAPI: one model class gives you a table, an API, migrations, auth and an admin."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Chinmay Talnikar" }]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
keywords = ["fastapi", "django", "web framework", "orm", "admin", "postgres", "sqlalchemy"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Framework :: FastAPI",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Database",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"alembic>=1.20.1",
|
|
26
|
+
"asyncpg>=0.32.0",
|
|
27
|
+
"fastapi>=0.159.1",
|
|
28
|
+
"jinja2>=3.1.6",
|
|
29
|
+
"pwdlib[argon2]>=0.3.1",
|
|
30
|
+
"pydantic>=2.14.1",
|
|
31
|
+
"pydantic-settings>=2.16.1",
|
|
32
|
+
"sqlalchemy[asyncio]>=2.1.6",
|
|
33
|
+
"typer>=0.27.3",
|
|
34
|
+
"uvicorn>=0.54.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/Carnage725/fyro"
|
|
39
|
+
Documentation = "https://carnage725.github.io/fyro/"
|
|
40
|
+
Source = "https://github.com/Carnage725/fyro"
|
|
41
|
+
Issues = "https://github.com/Carnage725/fyro/issues"
|
|
42
|
+
Changelog = "https://github.com/Carnage725/fyro/blob/main/CHANGELOG.md"
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
fyro = "fyro.cli:main"
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.10.11,<0.11"]
|
|
49
|
+
build-backend = "uv_build"
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
asyncio_mode = "auto"
|
|
54
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
55
|
+
markers = ["slow: end-to-end tests that start real processes"]
|
|
56
|
+
timeout = 120 # pytest-timeout: a hung test fails instead of blocking the run
|
|
57
|
+
|
|
58
|
+
[dependency-groups]
|
|
59
|
+
dev = [
|
|
60
|
+
"httpx>=0.28.1",
|
|
61
|
+
"hypothesis>=6.170.5",
|
|
62
|
+
"pytest>=9.1.1",
|
|
63
|
+
"pytest-asyncio>=1.4.0",
|
|
64
|
+
"pytest-randomly>=5.0.0",
|
|
65
|
+
"pytest-timeout>=2.4.0",
|
|
66
|
+
"ruff>=0.17.1",
|
|
67
|
+
]
|
|
68
|
+
docs = [
|
|
69
|
+
"zensical==0.0.69",
|
|
70
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The admin: /admin pages for superusers, mounted by create_app() when fyro.auth is installed."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
from starlette.datastructures import MutableHeaders
|
|
8
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
9
|
+
|
|
10
|
+
from fyro.admin.routes import AdminResponse, router
|
|
11
|
+
|
|
12
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
13
|
+
SECURITY_HEADERS = {
|
|
14
|
+
"Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'",
|
|
15
|
+
"X-Frame-Options": "DENY",
|
|
16
|
+
"Referrer-Policy": "same-origin",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AdminSecurityHeaders:
|
|
21
|
+
"""Adds the admin's security headers to every /admin response (pure ASGI)."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
24
|
+
self.app = app
|
|
25
|
+
|
|
26
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
27
|
+
if scope["type"] != "http" or not scope["path"].startswith("/admin"):
|
|
28
|
+
await self.app(scope, receive, send)
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
async def send_with_headers(message: Message) -> None:
|
|
32
|
+
if message["type"] == "http.response.start":
|
|
33
|
+
headers = MutableHeaders(scope=message)
|
|
34
|
+
for name, value in SECURITY_HEADERS.items():
|
|
35
|
+
headers[name] = value
|
|
36
|
+
await send(message)
|
|
37
|
+
|
|
38
|
+
await self.app(scope, receive, send_with_headers)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _admin_response(request: Request, exc: Exception) -> object:
|
|
42
|
+
return exc.response # type: ignore[attr-defined]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mount(app: FastAPI) -> None:
|
|
46
|
+
# Static first: /admin/static/... must not match the /admin/{app}/{model} page route.
|
|
47
|
+
app.mount("/admin/static", StaticFiles(directory=STATIC_DIR), name="admin-static")
|
|
48
|
+
app.include_router(router)
|
|
49
|
+
app.add_exception_handler(AdminResponse, _admin_response)
|
|
50
|
+
app.add_middleware(AdminSecurityHeaders)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Turn model fields into HTML form widgets, and submitted form data back into schema input."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import UTC, date, datetime
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ValidationError
|
|
10
|
+
|
|
11
|
+
from fyro.models.base import _unwrap_optional
|
|
12
|
+
from fyro.models.fields import MISSING
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FormField:
|
|
17
|
+
name: str
|
|
18
|
+
label: str
|
|
19
|
+
widget: str # text | textarea | checkbox | number | date | datetime-local | select | password
|
|
20
|
+
value: str = ""
|
|
21
|
+
checked: bool = False
|
|
22
|
+
options: list[tuple[str, str]] = field(default_factory=list)
|
|
23
|
+
required: bool = False
|
|
24
|
+
readonly: bool = False
|
|
25
|
+
help: str = ""
|
|
26
|
+
error: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def label(name: str) -> str:
|
|
30
|
+
return name.replace("_", " ").capitalize()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def widget_for(tp: Any, info: Any) -> str:
|
|
34
|
+
if tp is bool:
|
|
35
|
+
return "checkbox"
|
|
36
|
+
if isinstance(tp, type) and issubclass(tp, enum.Enum):
|
|
37
|
+
return "select"
|
|
38
|
+
if tp in (int, float, Decimal):
|
|
39
|
+
return "number"
|
|
40
|
+
if tp is datetime:
|
|
41
|
+
return "datetime-local"
|
|
42
|
+
if tp is date:
|
|
43
|
+
return "date"
|
|
44
|
+
if tp is str and info.max_length is None:
|
|
45
|
+
return "textarea"
|
|
46
|
+
return "text"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def as_input(value: Any) -> str:
|
|
50
|
+
"""A Python value as the string an <input> holds."""
|
|
51
|
+
if value is None:
|
|
52
|
+
return ""
|
|
53
|
+
if isinstance(value, enum.Enum):
|
|
54
|
+
return str(value.value)
|
|
55
|
+
if isinstance(value, datetime):
|
|
56
|
+
return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M")
|
|
57
|
+
if isinstance(value, date):
|
|
58
|
+
return value.isoformat()
|
|
59
|
+
return str(value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def display(value: Any) -> str:
|
|
63
|
+
"""A Python value as list-table text."""
|
|
64
|
+
if value is None:
|
|
65
|
+
return "—"
|
|
66
|
+
if isinstance(value, bool):
|
|
67
|
+
return "yes" if value else "no"
|
|
68
|
+
if isinstance(value, datetime):
|
|
69
|
+
return value.astimezone(UTC).strftime("%Y-%m-%d %H:%M UTC")
|
|
70
|
+
text = as_input(value)
|
|
71
|
+
return text if len(text) <= 80 else text[:79] + "…"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_fields(
|
|
75
|
+
model: Any, schema: type[BaseModel], instance: Any = None, *, editing: bool = False
|
|
76
|
+
) -> list[FormField]:
|
|
77
|
+
"""Editable fields come from the schema; read_only ones are shown, never submitted."""
|
|
78
|
+
fields: list[FormField] = []
|
|
79
|
+
for name, info in model.__fyro_fields__.items():
|
|
80
|
+
if name in schema.model_fields:
|
|
81
|
+
tp, nullable = _unwrap_optional(schema.model_fields[name].annotation)
|
|
82
|
+
widget = widget_for(tp, info)
|
|
83
|
+
current = getattr(instance, name) if instance is not None else None
|
|
84
|
+
if instance is None and info.default is not MISSING:
|
|
85
|
+
current = info.default
|
|
86
|
+
form_field = FormField(
|
|
87
|
+
name=name,
|
|
88
|
+
label=label(name),
|
|
89
|
+
widget=widget,
|
|
90
|
+
value="" if info.write_only else as_input(current),
|
|
91
|
+
checked=bool(current) if widget == "checkbox" else False,
|
|
92
|
+
options=[(str(m.value), m.name.title()) for m in tp] if widget == "select" else [],
|
|
93
|
+
required=not editing and not nullable and widget != "checkbox"
|
|
94
|
+
and info.default is MISSING and info.default_factory is None,
|
|
95
|
+
help="Leave blank to keep the current value." if editing and info.write_only else "",
|
|
96
|
+
)
|
|
97
|
+
fields.append(form_field)
|
|
98
|
+
elif instance is not None and info.read_only and not info.write_only:
|
|
99
|
+
fields.append(
|
|
100
|
+
FormField(
|
|
101
|
+
name=name, label=label(name), widget="text",
|
|
102
|
+
value=display(getattr(instance, name)), readonly=True,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return fields
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse(
|
|
109
|
+
model: Any, schema: type[BaseModel], form: dict[str, list[str]], *, editing: bool
|
|
110
|
+
) -> tuple[dict[str, Any], dict[str, str]]:
|
|
111
|
+
"""Validate submitted form data with `schema`. Returns (values, errors by field)."""
|
|
112
|
+
raw: dict[str, Any] = {}
|
|
113
|
+
for name, schema_field in schema.model_fields.items():
|
|
114
|
+
info = model.__fyro_fields__[name]
|
|
115
|
+
tp, nullable = _unwrap_optional(schema_field.annotation)
|
|
116
|
+
if tp is bool:
|
|
117
|
+
raw[name] = name in form # an unchecked checkbox is simply absent
|
|
118
|
+
continue
|
|
119
|
+
value = form.get(name, [""])[0]
|
|
120
|
+
if value == "":
|
|
121
|
+
if editing and info.write_only:
|
|
122
|
+
continue # blank means "keep the current value"
|
|
123
|
+
if nullable:
|
|
124
|
+
raw[name] = None
|
|
125
|
+
elif tp is str and editing:
|
|
126
|
+
raw[name] = ""
|
|
127
|
+
continue # create: let the schema apply its default or report "Field required"
|
|
128
|
+
raw[name] = value
|
|
129
|
+
try:
|
|
130
|
+
validated = schema.model_validate(raw)
|
|
131
|
+
except ValidationError as error:
|
|
132
|
+
return {}, {str(e["loc"][0]) if e["loc"] else "__all__": e["msg"] for e in error.errors()}
|
|
133
|
+
values = validated.model_dump(exclude_unset=True)
|
|
134
|
+
for name, value in values.items():
|
|
135
|
+
if isinstance(value, datetime) and value.tzinfo is None:
|
|
136
|
+
values[name] = value.replace(tzinfo=UTC) # datetime-local has no zone: treat as UTC
|
|
137
|
+
return values, {}
|