backend-dash 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend_dash-0.1.0.dist-info/METADATA +178 -0
- backend_dash-0.1.0.dist-info/RECORD +49 -0
- backend_dash-0.1.0.dist-info/WHEEL +4 -0
- backend_dash-0.1.0.dist-info/entry_points.txt +2 -0
- backend_dash-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastapi_scaffolder/__init__.py +1 -0
- fastapi_scaffolder/cli.py +117 -0
- fastapi_scaffolder/templates/db_migrations/alembic/README +1 -0
- fastapi_scaffolder/templates/db_migrations/alembic/env.py +52 -0
- fastapi_scaffolder/templates/db_migrations/alembic/script.py.mako +28 -0
- fastapi_scaffolder/templates/db_migrations/alembic/versions/.gitkeep +0 -0
- fastapi_scaffolder/templates/db_migrations/alembic.ini +149 -0
- fastapi_scaffolder/templates/fastapi_sqlite/.env.example.j2 +63 -0
- fastapi_scaffolder/templates/fastapi_sqlite/README.md.j2 +40 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/api/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/api/deps.py.j2 +48 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/api/v1/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/api/v1/api.py.j2 +14 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/api/v1/routes_item.py +42 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/core/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/core/config.py.j2 +49 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/core/exceptions.py +41 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/crud/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/crud/item.py +38 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/db/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/db/base.py.j2 +9 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/db/base_class.py +10 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/db/init_db.py +11 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/db/session.py +28 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/main.py.j2 +44 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/models/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/models/item.py +17 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/schemas/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/schemas/item.py +27 -0
- fastapi_scaffolder/templates/fastapi_sqlite/app/utils/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite/requirements.txt.j2 +19 -0
- fastapi_scaffolder/templates/fastapi_sqlite/tests/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite/tests/test_items.py +48 -0
- fastapi_scaffolder/templates/fastapi_sqlite_auth/app/api/v1/routes_auth.py +57 -0
- fastapi_scaffolder/templates/fastapi_sqlite_auth/app/core/security.py +35 -0
- fastapi_scaffolder/templates/fastapi_sqlite_auth/app/models/user.py +20 -0
- fastapi_scaffolder/templates/fastapi_sqlite_auth/app/repositories/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite_auth/app/repositories/base.py +46 -0
- fastapi_scaffolder/templates/fastapi_sqlite_auth/app/repositories/user_repository.py +15 -0
- fastapi_scaffolder/templates/fastapi_sqlite_auth/app/schemas/user.py +30 -0
- fastapi_scaffolder/templates/fastapi_sqlite_auth/app/services/__init__.py +0 -0
- fastapi_scaffolder/templates/fastapi_sqlite_auth/app/services/auth_service.py +108 -0
- fastapi_scaffolder/templates/fastapi_sqlite_auth/tests/test_auth.py +85 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: backend-dash
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scaffold a FastAPI + SQLAlchemy backend with SQLite/Postgres/MySQL and optional JWT + Google OAuth auth
|
|
5
|
+
Project-URL: Homepage, https://github.com/prathamdmehta/backend-dash
|
|
6
|
+
Project-URL: Repository, https://github.com/prathamdmehta/backend-dash
|
|
7
|
+
Author: Pratham
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: backend,boilerplate,cli,fastapi,scaffold,sqlalchemy
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
20
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: jinja2>=3.1
|
|
23
|
+
Requires-Dist: typer>=0.12
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# backend-dash
|
|
27
|
+
|
|
28
|
+
A CLI that generates ready-to-code FastAPI backend projects with SQLAlchemy, SQLite, PostgreSQL, MySQL, Alembic migrations, CRUD examples, tests, and optional JWT + Google OAuth authentication.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Scaffold a complete FastAPI backend in seconds.
|
|
33
|
+
- Supports `--db=sqlite|postgres|mysql` with SQLite as the default.
|
|
34
|
+
- Generates a working SQLAlchemy setup and DB session wiring.
|
|
35
|
+
- Adds an example CRUD resource so you can start from real code, not boilerplate.
|
|
36
|
+
- Includes tests for the generated project.
|
|
37
|
+
- Adds Alembic migrations for PostgreSQL and MySQL.
|
|
38
|
+
- Keeps SQLite simple with `create_all` for zero-config local development.
|
|
39
|
+
- Optional `--auth` mode adds JWT + Google OAuth auth support.
|
|
40
|
+
- Clear validation for unsupported database values.
|
|
41
|
+
- Clean `.env.example` setup for local development and migrations.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
### One-off usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
uvx backend-dash init "My API"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Install locally
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install backend-dash
|
|
55
|
+
backend-dash init "My API"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Editable install for development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/prathamdmehta/backend-dash.git
|
|
62
|
+
cd backend-dash
|
|
63
|
+
pip install -e . --break-system-packages
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
Create a new project:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
backend-dash init "Demo Project"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Create a project with auth:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
backend-dash init "Demo Project Auth" --auth
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Choose a database:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
backend-dash init "My API" --db=sqlite
|
|
84
|
+
backend-dash init "My API" --db=postgres
|
|
85
|
+
backend-dash init "My API" --db=mysql
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Combine database and auth:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
backend-dash init "My API" --db=postgres --auth
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## What gets generated
|
|
95
|
+
|
|
96
|
+
### Base project
|
|
97
|
+
|
|
98
|
+
- FastAPI app structure.
|
|
99
|
+
- SQLAlchemy session and base model wiring.
|
|
100
|
+
- Example item resource with CRUD routes.
|
|
101
|
+
- Pydantic schemas and models.
|
|
102
|
+
- Pytest test setup.
|
|
103
|
+
|
|
104
|
+
### With `--auth`
|
|
105
|
+
|
|
106
|
+
- Repository layer with a generic `BaseRepository` and `UserRepository`.
|
|
107
|
+
- Auth service for registration, login, JWT issuance, and Google token exchange.
|
|
108
|
+
- Validation with proper schema constraints.
|
|
109
|
+
- Centralized error handling with a consistent error response shape.
|
|
110
|
+
- Auth routes for register, login, me, Google login, and Google callback.
|
|
111
|
+
|
|
112
|
+
## Database support
|
|
113
|
+
|
|
114
|
+
### SQLite
|
|
115
|
+
|
|
116
|
+
SQLite is the default and uses `create_all` for a simple local setup.
|
|
117
|
+
|
|
118
|
+
### PostgreSQL
|
|
119
|
+
|
|
120
|
+
For PostgreSQL, the scaffold uses Alembic migrations and real schema management.
|
|
121
|
+
|
|
122
|
+
### MySQL
|
|
123
|
+
|
|
124
|
+
For MySQL, the scaffold also uses Alembic migrations and the correct MySQL driver setup.
|
|
125
|
+
|
|
126
|
+
## Migrations
|
|
127
|
+
|
|
128
|
+
For PostgreSQL and MySQL, the project generates Alembic configuration and an `env.py` wired to `DATABASE_URL` and your app metadata.
|
|
129
|
+
|
|
130
|
+
Typical workflow:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
alembic revision --autogenerate -m "create initial tables"
|
|
134
|
+
alembic upgrade head
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The generated `.env.example` includes the right migration flow so you can move from scaffold to production-style schema management quickly.
|
|
138
|
+
|
|
139
|
+
## Project structure
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
my_api/
|
|
143
|
+
├── app/
|
|
144
|
+
├── tests/
|
|
145
|
+
├── alembic/
|
|
146
|
+
├── alembic.ini
|
|
147
|
+
└── .env.example
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
If `--auth` is enabled, additional auth-related modules are included inside the app structure.
|
|
151
|
+
|
|
152
|
+
## Example
|
|
153
|
+
|
|
154
|
+
Generate a new API project:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
backend-dash init "Orders API" --db=postgres --auth
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
This creates a FastAPI backend with database wiring, migrations, tests, and authentication already in place, so you can start on business logic immediately.
|
|
161
|
+
|
|
162
|
+
## Development
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
git clone https://github.com/prathamdmehta/backend-dash.git
|
|
166
|
+
cd backend-dash
|
|
167
|
+
pip install -e . --break-system-packages
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Then run the CLI locally:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
backend-dash init "Demo Project"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
fastapi_scaffolder/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
fastapi_scaffolder/cli.py,sha256=wzm5YuYWtZYO8QxRLSQz9HxncHEtCXMkGlBwPgoJIt4,4146
|
|
3
|
+
fastapi_scaffolder/templates/db_migrations/alembic.ini,sha256=HeRE5SAnRyRnGMP4AmTI_mTqvGlyZ4aWcHce2xQAUP0,5008
|
|
4
|
+
fastapi_scaffolder/templates/db_migrations/alembic/README,sha256=MVlc9TYmr57RbhXET6QxgyCcwWP7w-vLkEsirENqiIQ,38
|
|
5
|
+
fastapi_scaffolder/templates/db_migrations/alembic/env.py,sha256=BphCR-n56m5bjbEFcY777rID22jVUzRlqPYmaEzKRG8,1410
|
|
6
|
+
fastapi_scaffolder/templates/db_migrations/alembic/script.py.mako,sha256=04kgeBtNMa4cCnG8CfQcKt6P6rnloIfj8wy0u_DBydM,704
|
|
7
|
+
fastapi_scaffolder/templates/db_migrations/alembic/versions/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
fastapi_scaffolder/templates/fastapi_sqlite/.env.example.j2,sha256=ixc6F9RVF70r9sbATCwQpY4m7DO5pKEUFpkfaNGbxJE,2588
|
|
9
|
+
fastapi_scaffolder/templates/fastapi_sqlite/README.md.j2,sha256=s5ASJ5d8wGwu_f9FqRe-DMP6WOqztBtASjvpFzpPdro,1338
|
|
10
|
+
fastapi_scaffolder/templates/fastapi_sqlite/requirements.txt.j2,sha256=qucRy-l9uqVSplXumr1X9CeEbWniHpWnZqZmv78unOo,333
|
|
11
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/main.py.j2,sha256=R68YUmDap9CezazOd42gJyy-j6Aq0nUcntr_B6Kmxb0,1386
|
|
13
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/api/deps.py.j2,sha256=o7Cepj_hgXjz9OusTtoVrbi_bTwgU_iU7TgLj18JnVo,1626
|
|
15
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/api/v1/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/api/v1/api.py.j2,sha256=2AqwfN8Wz0kbUSSocoaWL9x7-uf6cbAyCrAsKNdySqM,396
|
|
17
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/api/v1/routes_item.py,sha256=XZph_eE0Cp00YB8hBR9ueOs3n6VjBv2P9PQ15kAKhiM,1470
|
|
18
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/core/config.py.j2,sha256=pDpv4n3bkuFsHpACGyfH6QlenlU39XThQnsxdQ3wLCs,1873
|
|
20
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/core/exceptions.py,sha256=BCymjibQ_xJB7D4EDuOr5_hRnq2SGZuxeaD5q_yO5I4,1027
|
|
21
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/crud/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/crud/item.py,sha256=JwvIhUcQLHOtnYVIA9GIi9cd-a3d7JqDVDD_Y58kjLo,1007
|
|
23
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/db/base.py.j2,sha256=wWHftpL1UuPco9dbmv0MQwIItTVDttDqqLKGe878SdI,320
|
|
25
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/db/base_class.py,sha256=Z9IrLqt-nc3JvhphZ3tShWsWyt5X8ZbdQ8znM-YhwTA,211
|
|
26
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/db/init_db.py,sha256=J1Ln9ZWY7Bhp9-fUR6O8SvP-xTCyxH9TvArwwWgb4uM,341
|
|
27
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/db/session.py,sha256=ww3Ci2kJUEqtpWKP_YEjMhdgvzTs0QkzbzQv4ITHkKA,795
|
|
28
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/models/item.py,sha256=EHHBQaKRJAnbaH1Q_mxzNRJm5kT9ShAxLVtg2H7j32Y,542
|
|
30
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
31
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/schemas/item.py,sha256=ZiU_R3M4SwHByFCnwjkH8RTeCqNgdKB_CGHDrIWm3J8,509
|
|
32
|
+
fastapi_scaffolder/templates/fastapi_sqlite/app/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
33
|
+
fastapi_scaffolder/templates/fastapi_sqlite/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
34
|
+
fastapi_scaffolder/templates/fastapi_sqlite/tests/test_items.py,sha256=KgPU_31AIrNLqdQ7Cz9sJp4VxVTnxIgoC91tTcmmudk,1272
|
|
35
|
+
fastapi_scaffolder/templates/fastapi_sqlite_auth/app/api/v1/routes_auth.py,sha256=W5gOESUiQUhTVBPSQ-IFczzweiMqo-oIdU2y5otBqCs,2067
|
|
36
|
+
fastapi_scaffolder/templates/fastapi_sqlite_auth/app/core/security.py,sha256=pjs-yXQZHNq8AnBgXTaV3ajnuk42ZeF8nUN_0z8wVEE,1189
|
|
37
|
+
fastapi_scaffolder/templates/fastapi_sqlite_auth/app/models/user.py,sha256=ith5OgahtQiKKlsRiPefpcQgLLwt6PZ1RmDbnNKrWzA,807
|
|
38
|
+
fastapi_scaffolder/templates/fastapi_sqlite_auth/app/repositories/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
39
|
+
fastapi_scaffolder/templates/fastapi_sqlite_auth/app/repositories/base.py,sha256=Ab6BGUc0ju9EgTNdOn1L9thJ5Bfqppj1zvO-mNm72yA,1384
|
|
40
|
+
fastapi_scaffolder/templates/fastapi_sqlite_auth/app/repositories/user_repository.py,sha256=WvswWWeVYJtiOpR8xzzSzV8K5RH0IayxrDij82iNFRE,510
|
|
41
|
+
fastapi_scaffolder/templates/fastapi_sqlite_auth/app/schemas/user.py,sha256=b-1g7YMxoL0nGS_icDzgEVnOX6uwAgimddXvkGt4CB8,628
|
|
42
|
+
fastapi_scaffolder/templates/fastapi_sqlite_auth/app/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
43
|
+
fastapi_scaffolder/templates/fastapi_sqlite_auth/app/services/auth_service.py,sha256=6L9HO5XKmioNEEa3jS4e2gSUG0zcLgWaEiBdeyPp31E,3887
|
|
44
|
+
fastapi_scaffolder/templates/fastapi_sqlite_auth/tests/test_auth.py,sha256=xSF_OsWGRrLRWUmbyUhQpkpSpxH8gU6KoLLyGJ6Wk1A,2707
|
|
45
|
+
backend_dash-0.1.0.dist-info/METADATA,sha256=4zrqv7ZpHRC39W8LeKmbheAAKBlHXmeCya3siAVHDxI,4614
|
|
46
|
+
backend_dash-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
47
|
+
backend_dash-0.1.0.dist-info/entry_points.txt,sha256=vJt6n0Jf_H6VmGyeAgfAIRs050jMWbY1q4FOnmF7Nj0,61
|
|
48
|
+
backend_dash-0.1.0.dist-info/licenses/LICENSE,sha256=z7OWR_tNA6V2Ct2-qkJzGye8ekWd40XASgOI7UBXh5c,1064
|
|
49
|
+
backend_dash-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pratham
|
|
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 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from jinja2 import Environment, FileSystemLoader
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
help="Scaffold a FastAPI + SQLAlchemy backend so you can skip the boilerplate."
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
TEMPLATES_ROOT = Path(__file__).parent / "templates"
|
|
13
|
+
VALID_DBS = ("sqlite", "postgres", "mysql")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def slugify(name: str) -> str:
|
|
17
|
+
"""Turn 'My Cool API' into 'my_cool_api' for use as a folder name."""
|
|
18
|
+
slug = re.sub(r"[^a-zA-Z0-9]+", "_", name.strip()).strip("_").lower()
|
|
19
|
+
return slug or "backend_project"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_template(template_dir: Path, target_dir: Path, context: dict) -> None:
|
|
23
|
+
env = Environment(loader=FileSystemLoader(str(template_dir)), keep_trailing_newline=True)
|
|
24
|
+
|
|
25
|
+
for src_path in sorted(template_dir.rglob("*")):
|
|
26
|
+
if src_path.is_dir():
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
rel_path = src_path.relative_to(template_dir)
|
|
30
|
+
rel_str = rel_path.as_posix()
|
|
31
|
+
is_template = rel_str.endswith(".j2")
|
|
32
|
+
out_rel = Path(rel_str[:-3]) if is_template else rel_path
|
|
33
|
+
out_path = target_dir / out_rel
|
|
34
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
if is_template:
|
|
37
|
+
rendered = env.get_template(rel_str).render(**context)
|
|
38
|
+
out_path.write_text(rendered, encoding="utf-8")
|
|
39
|
+
else:
|
|
40
|
+
shutil.copy2(src_path, out_path)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def init(
|
|
45
|
+
project_name: str = typer.Argument(..., help="Human-readable project name, e.g. 'My API'"),
|
|
46
|
+
directory: str = typer.Option(
|
|
47
|
+
None,
|
|
48
|
+
"--dir",
|
|
49
|
+
help="Folder to create the project in. Defaults to a slugified version of the project name.",
|
|
50
|
+
),
|
|
51
|
+
auth: bool = typer.Option(
|
|
52
|
+
False,
|
|
53
|
+
"--auth",
|
|
54
|
+
help="Include JWT + Google OAuth authentication (repository pattern, service layer, "
|
|
55
|
+
"centralized error handling, login/register/me routes).",
|
|
56
|
+
),
|
|
57
|
+
db: str = typer.Option(
|
|
58
|
+
"sqlite",
|
|
59
|
+
"--db",
|
|
60
|
+
help="Database backend: sqlite (default, zero setup), postgres, or mysql "
|
|
61
|
+
"(both include Alembic migrations instead of auto-created tables).",
|
|
62
|
+
),
|
|
63
|
+
):
|
|
64
|
+
"""
|
|
65
|
+
Generate a new FastAPI + SQLAlchemy + SQLite backend project.
|
|
66
|
+
"""
|
|
67
|
+
db = db.lower()
|
|
68
|
+
if db not in VALID_DBS:
|
|
69
|
+
typer.secho(f"--db must be one of {VALID_DBS}, got '{db}'.", fg=typer.colors.RED)
|
|
70
|
+
raise typer.Exit(code=1)
|
|
71
|
+
|
|
72
|
+
project_slug = slugify(directory or project_name)
|
|
73
|
+
target_dir = Path.cwd() / project_slug
|
|
74
|
+
|
|
75
|
+
if target_dir.exists() and any(target_dir.iterdir()):
|
|
76
|
+
typer.secho(f"'{project_slug}' already exists and is not empty. Aborting.", fg=typer.colors.RED)
|
|
77
|
+
raise typer.Exit(code=1)
|
|
78
|
+
|
|
79
|
+
context = {"project_name": project_name, "project_slug": project_slug, "auth": auth, "db": db}
|
|
80
|
+
|
|
81
|
+
render_template(TEMPLATES_ROOT / "fastapi_sqlite", target_dir, context)
|
|
82
|
+
if auth:
|
|
83
|
+
render_template(TEMPLATES_ROOT / "fastapi_sqlite_auth", target_dir, context)
|
|
84
|
+
if db in ("postgres", "mysql"):
|
|
85
|
+
render_template(TEMPLATES_ROOT / "db_migrations", target_dir, context)
|
|
86
|
+
|
|
87
|
+
typer.secho(f"\n✅ Created '{project_name}' in ./{project_slug}\n", fg=typer.colors.GREEN, bold=True)
|
|
88
|
+
typer.echo("Next steps:")
|
|
89
|
+
typer.echo(f" cd {project_slug}")
|
|
90
|
+
typer.echo(" pip install -r requirements.txt # or: uv pip install -r requirements.txt")
|
|
91
|
+
typer.echo(" cp .env.example .env")
|
|
92
|
+
if db in ("postgres", "mysql"):
|
|
93
|
+
typer.echo(f" # edit .env with your real {db} credentials, then:")
|
|
94
|
+
typer.echo(' alembic revision --autogenerate -m "initial tables"')
|
|
95
|
+
typer.echo(" alembic upgrade head")
|
|
96
|
+
if auth:
|
|
97
|
+
typer.echo(" # (optional) set GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET in .env for Google login")
|
|
98
|
+
typer.echo(" uvicorn app.main:app --reload")
|
|
99
|
+
if auth:
|
|
100
|
+
typer.echo("\nAuth endpoints: POST /api/v1/auth/register, POST /api/v1/auth/login, "
|
|
101
|
+
"GET /api/v1/auth/me, GET /api/v1/auth/google/login")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def version():
|
|
106
|
+
"""Print the fastapi-scaffolder version."""
|
|
107
|
+
from fastapi_scaffolder import __version__
|
|
108
|
+
|
|
109
|
+
typer.echo(__version__)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main():
|
|
113
|
+
app()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Generic single-database configuration.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from logging.config import fileConfig
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import engine_from_config
|
|
4
|
+
from sqlalchemy import pool
|
|
5
|
+
|
|
6
|
+
from alembic import context
|
|
7
|
+
|
|
8
|
+
# Pull the connection URL and model metadata from the app itself,
|
|
9
|
+
# so alembic.ini never needs a hardcoded/duplicated DATABASE_URL.
|
|
10
|
+
from app.core.config import settings
|
|
11
|
+
from app.db.base import Base # noqa: F401 (imports every model)
|
|
12
|
+
|
|
13
|
+
config = context.config
|
|
14
|
+
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
|
15
|
+
|
|
16
|
+
if config.config_file_name is not None:
|
|
17
|
+
fileConfig(config.config_file_name)
|
|
18
|
+
|
|
19
|
+
target_metadata = Base.metadata
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_migrations_offline() -> None:
|
|
23
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
24
|
+
context.configure(
|
|
25
|
+
url=url,
|
|
26
|
+
target_metadata=target_metadata,
|
|
27
|
+
literal_binds=True,
|
|
28
|
+
dialect_opts={"paramstyle": "named"},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
with context.begin_transaction():
|
|
32
|
+
context.run_migrations()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_migrations_online() -> None:
|
|
36
|
+
connectable = engine_from_config(
|
|
37
|
+
config.get_section(config.config_ini_section, {}),
|
|
38
|
+
prefix="sqlalchemy.",
|
|
39
|
+
poolclass=pool.NullPool,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
with connectable.connect() as connection:
|
|
43
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
44
|
+
|
|
45
|
+
with context.begin_transaction():
|
|
46
|
+
context.run_migrations()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if context.is_offline_mode():
|
|
50
|
+
run_migrations_offline()
|
|
51
|
+
else:
|
|
52
|
+
run_migrations_online()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
${imports if imports else ""}
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = ${repr(up_revision)}
|
|
16
|
+
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
"""Upgrade schema."""
|
|
23
|
+
${upgrades if upgrades else "pass"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def downgrade() -> None:
|
|
27
|
+
"""Downgrade schema."""
|
|
28
|
+
${downgrades if downgrades else "pass"}
|
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# A generic, single database configuration.
|
|
2
|
+
|
|
3
|
+
[alembic]
|
|
4
|
+
# path to migration scripts.
|
|
5
|
+
# this is typically a path given in POSIX (e.g. forward slashes)
|
|
6
|
+
# format, relative to the token %(here)s which refers to the location of this
|
|
7
|
+
# ini file
|
|
8
|
+
script_location = %(here)s/alembic
|
|
9
|
+
|
|
10
|
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
|
11
|
+
# Uncomment the line below if you want the files to be prepended with date and time
|
|
12
|
+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
|
13
|
+
# for all available tokens
|
|
14
|
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
|
15
|
+
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
|
16
|
+
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
|
17
|
+
|
|
18
|
+
# sys.path path, will be prepended to sys.path if present.
|
|
19
|
+
# defaults to the current working directory. for multiple paths, the path separator
|
|
20
|
+
# is defined by "path_separator" below.
|
|
21
|
+
prepend_sys_path = .
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# timezone to use when rendering the date within the migration file
|
|
25
|
+
# as well as the filename.
|
|
26
|
+
# If specified, requires the tzdata library which can be installed by adding
|
|
27
|
+
# `alembic[tz]` to the pip requirements.
|
|
28
|
+
# string value is passed to ZoneInfo()
|
|
29
|
+
# leave blank for localtime
|
|
30
|
+
# timezone =
|
|
31
|
+
|
|
32
|
+
# max length of characters to apply to the "slug" field
|
|
33
|
+
# truncate_slug_length = 40
|
|
34
|
+
|
|
35
|
+
# set to 'true' to run the environment during
|
|
36
|
+
# the 'revision' command, regardless of autogenerate
|
|
37
|
+
# revision_environment = false
|
|
38
|
+
|
|
39
|
+
# set to 'true' to allow .pyc and .pyo files without
|
|
40
|
+
# a source .py file to be detected as revisions in the
|
|
41
|
+
# versions/ directory
|
|
42
|
+
# sourceless = false
|
|
43
|
+
|
|
44
|
+
# version location specification; This defaults
|
|
45
|
+
# to <script_location>/versions. When using multiple version
|
|
46
|
+
# directories, initial revisions must be specified with --version-path.
|
|
47
|
+
# The path separator used here should be the separator specified by "path_separator"
|
|
48
|
+
# below.
|
|
49
|
+
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
|
50
|
+
|
|
51
|
+
# path_separator; This indicates what character is used to split lists of file
|
|
52
|
+
# paths, including version_locations and prepend_sys_path within configparser
|
|
53
|
+
# files such as alembic.ini.
|
|
54
|
+
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
|
55
|
+
# to provide os-dependent path splitting.
|
|
56
|
+
#
|
|
57
|
+
# Note that in order to support legacy alembic.ini files, this default does NOT
|
|
58
|
+
# take place if path_separator is not present in alembic.ini. If this
|
|
59
|
+
# option is omitted entirely, fallback logic is as follows:
|
|
60
|
+
#
|
|
61
|
+
# 1. Parsing of the version_locations option falls back to using the legacy
|
|
62
|
+
# "version_path_separator" key, which if absent then falls back to the legacy
|
|
63
|
+
# behavior of splitting on spaces and/or commas.
|
|
64
|
+
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
|
65
|
+
# behavior of splitting on spaces, commas, or colons.
|
|
66
|
+
#
|
|
67
|
+
# Valid values for path_separator are:
|
|
68
|
+
#
|
|
69
|
+
# path_separator = :
|
|
70
|
+
# path_separator = ;
|
|
71
|
+
# path_separator = space
|
|
72
|
+
# path_separator = newline
|
|
73
|
+
#
|
|
74
|
+
# Use os.pathsep. Default configuration used for new projects.
|
|
75
|
+
path_separator = os
|
|
76
|
+
|
|
77
|
+
# set to 'true' to search source files recursively
|
|
78
|
+
# in each "version_locations" directory
|
|
79
|
+
# new in Alembic version 1.10
|
|
80
|
+
# recursive_version_locations = false
|
|
81
|
+
|
|
82
|
+
# the output encoding used when revision files
|
|
83
|
+
# are written from script.py.mako
|
|
84
|
+
# output_encoding = utf-8
|
|
85
|
+
|
|
86
|
+
# database URL. This is consumed by the user-maintained env.py script only.
|
|
87
|
+
# other means of configuring database URLs may be customized within the env.py
|
|
88
|
+
# file.
|
|
89
|
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
[post_write_hooks]
|
|
93
|
+
# post_write_hooks defines scripts or Python functions that are run
|
|
94
|
+
# on newly generated revision scripts. See the documentation for further
|
|
95
|
+
# detail and examples
|
|
96
|
+
|
|
97
|
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
|
98
|
+
# hooks = black
|
|
99
|
+
# black.type = console_scripts
|
|
100
|
+
# black.entrypoint = black
|
|
101
|
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
|
102
|
+
|
|
103
|
+
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
|
104
|
+
# hooks = ruff
|
|
105
|
+
# ruff.type = module
|
|
106
|
+
# ruff.module = ruff
|
|
107
|
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
|
108
|
+
|
|
109
|
+
# Alternatively, use the exec runner to execute a binary found on your PATH
|
|
110
|
+
# hooks = ruff
|
|
111
|
+
# ruff.type = exec
|
|
112
|
+
# ruff.executable = ruff
|
|
113
|
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
|
114
|
+
|
|
115
|
+
# Logging configuration. This is also consumed by the user-maintained
|
|
116
|
+
# env.py script only.
|
|
117
|
+
[loggers]
|
|
118
|
+
keys = root,sqlalchemy,alembic
|
|
119
|
+
|
|
120
|
+
[handlers]
|
|
121
|
+
keys = console
|
|
122
|
+
|
|
123
|
+
[formatters]
|
|
124
|
+
keys = generic
|
|
125
|
+
|
|
126
|
+
[logger_root]
|
|
127
|
+
level = WARNING
|
|
128
|
+
handlers = console
|
|
129
|
+
qualname =
|
|
130
|
+
|
|
131
|
+
[logger_sqlalchemy]
|
|
132
|
+
level = WARNING
|
|
133
|
+
handlers =
|
|
134
|
+
qualname = sqlalchemy.engine
|
|
135
|
+
|
|
136
|
+
[logger_alembic]
|
|
137
|
+
level = INFO
|
|
138
|
+
handlers =
|
|
139
|
+
qualname = alembic
|
|
140
|
+
|
|
141
|
+
[handler_console]
|
|
142
|
+
class = StreamHandler
|
|
143
|
+
args = (sys.stderr,)
|
|
144
|
+
level = NOTSET
|
|
145
|
+
formatter = generic
|
|
146
|
+
|
|
147
|
+
[formatter_generic]
|
|
148
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
149
|
+
datefmt = %H:%M:%S
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
PROJECT_NAME="{{ project_name }}"
|
|
2
|
+
{% if db == "postgres" -%}
|
|
3
|
+
# Update user/password/host/port/dbname for your actual Postgres instance.
|
|
4
|
+
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/{{ project_slug }}
|
|
5
|
+
{% elif db == "mysql" -%}
|
|
6
|
+
# Update user/password/host/port/dbname for your actual MySQL instance.
|
|
7
|
+
DATABASE_URL=mysql+pymysql://root:root@localhost:3306/{{ project_slug }}
|
|
8
|
+
{% else -%}
|
|
9
|
+
DATABASE_URL=sqlite:///./app.db
|
|
10
|
+
{% endif -%}
|
|
11
|
+
DEBUG=True
|
|
12
|
+
{% if db in ("postgres", "mysql") -%}
|
|
13
|
+
|
|
14
|
+
# --- Migrations ---
|
|
15
|
+
# This project's schema is managed by Alembic, not auto-created on
|
|
16
|
+
# startup. Before running the app for the first time:
|
|
17
|
+
# 1. Make sure the database above exists and is reachable.
|
|
18
|
+
# 2. alembic revision --autogenerate -m "initial tables"
|
|
19
|
+
# 3. alembic upgrade head
|
|
20
|
+
# Whenever you change a model, repeat steps 2-3 to add a new migration.
|
|
21
|
+
{% endif -%}
|
|
22
|
+
{% if auth -%}
|
|
23
|
+
|
|
24
|
+
# --- Auth (JWT) ---
|
|
25
|
+
|
|
26
|
+
# Signs and verifies your JWTs. Never commit a real value — this
|
|
27
|
+
# placeholder is fine for local dev only.
|
|
28
|
+
# Generate one with:
|
|
29
|
+
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
30
|
+
SECRET_KEY=dev-secret-change-me-this-is-not-secure-32chars
|
|
31
|
+
|
|
32
|
+
# HMAC algorithm used to sign the JWT. HS256 is fine for most apps
|
|
33
|
+
# (single-server secret). Leave as-is unless you have a specific reason.
|
|
34
|
+
ALGORITHM=HS256
|
|
35
|
+
|
|
36
|
+
# How long an access token stays valid, in minutes. 1440 = 24 hours.
|
|
37
|
+
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
|
38
|
+
|
|
39
|
+
# --- Google OAuth ---
|
|
40
|
+
# Needed only for the "Log in with Google" flow (/auth/google/login).
|
|
41
|
+
# Password-based register/login works without any of this.
|
|
42
|
+
#
|
|
43
|
+
# How to get these values:
|
|
44
|
+
# 1. Go to https://console.cloud.google.com/apis/credentials
|
|
45
|
+
# 2. Create a project (or select an existing one).
|
|
46
|
+
# 3. Click "Create Credentials" -> "OAuth client ID".
|
|
47
|
+
# 4. If prompted, configure the "OAuth consent screen" first
|
|
48
|
+
# (External user type is fine for testing; add your own email
|
|
49
|
+
# as a test user so you can log in before the app is verified).
|
|
50
|
+
# 5. Application type: "Web application".
|
|
51
|
+
# 6. Under "Authorized redirect URIs", add the exact value of
|
|
52
|
+
# GOOGLE_REDIRECT_URI below (must match exactly, including the
|
|
53
|
+
# port, or Google will reject the callback).
|
|
54
|
+
# 7. Click "Create" - Google shows you the Client ID and Client
|
|
55
|
+
# Secret. Paste them below.
|
|
56
|
+
GOOGLE_CLIENT_ID=
|
|
57
|
+
GOOGLE_CLIENT_SECRET=
|
|
58
|
+
|
|
59
|
+
# Must exactly match an "Authorized redirect URI" configured in step 6
|
|
60
|
+
# above. Update the host/port here if you deploy somewhere other than
|
|
61
|
+
# localhost:8000.
|
|
62
|
+
GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/api/v1/auth/google/callback
|
|
63
|
+
{% endif -%}
|