-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathdocumentation.txt
More file actions
1549 lines (1095 loc) · 64.4 KB
/
Copy pathdocumentation.txt
File metadata and controls
1549 lines (1095 loc) · 64.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
---
title: "FastAPI, Jinja2, PostgreSQL Webapp Template"
description: "A production-ready web application template combining FastAPI, Jinja2, and PostgreSQL with secure authentication, role-based access control, and easy cloud deployment."
---

## Quickstart
This quickstart guide provides a high-level overview. See the full documentation for comprehensive information on [features](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/index.html), [installation](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html), [conventions, code style, and customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/customization.html), [deployment to cloud platforms](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/deployment.html), and [contributing](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/contributing.html).
## Features
This template combines three of the most lightweight and performant open-source web development frameworks into a customizable webapp template with:
- Pure Python backend
- Minimal-Javascript frontend
- Powerful, easy-to-manage database
The template also includes full-featured secure auth with:
- Token-based authentication
- Password recovery flow
- Role-based access control system
## Design Philosophy
The design philosophy of the template is to prefer low-level, best-in-class open-source frameworks that offer flexibility, scalability, and performance without vendor-lock-in. You'll find the template amazingly easy not only to understand and customize, but also to deploy to any major cloud hosting platform.
## Tech Stack
**Core frameworks:**
- [FastAPI](https://fastapi.tiangolo.com/): scalable, high-performance, type-annotated Python web backend framework
- [PostgreSQL](https://www.postgresql.org/): the world's most advanced open-source database engine
- [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/): frontend HTML templating engine
- [SQLModel](https://sqlmodel.tiangolo.com/): easy-to-use Python ORM
**Additional technologies:**
- [uv](https://docs.astral.sh/uv/): Python dependency manager
- [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework
- [Docker](https://www.docker.com/): development containerization
- [Github Actions](https://docs.github.com/en/actions): CI/CD pipeline
- [Quarto](https://quarto.org/docs/): simple documentation website renderer
- [ty](https://docs.astral.sh/ty/): static type checker for Python
- [Bootstrap](https://getbootstrap.com/): HTML/CSS styler
- [Resend](https://resend.com/): zero- or low-cost email service used for password recovery
## Installation
For comprehensive installation instructions, see the [installation page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html).
### uv
MacOS and Linux:
``` bash
wget -qO- https://astral.sh/uv/install.sh | sh
```
Windows:
``` bash
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```
See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for more information.
### Python
Install Python 3.12 or higher from either the official [downloads page](https://www.python.org/downloads/) or using uv:
``` bash
# Installs the latest version
uv python install
```
### Docker and Docker Compose
Install Docker Desktop and Coker Compose for your operating system by following the [instructions in the documentation](https://docs.docker.com/compose/install/).
### PostgreSQL headers
For Ubuntu/Debian:
``` bash
sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev
```
For macOS:
``` bash
brew install postgresql
```
For Windows:
- No installation required
### Python dependencies
From the root directory, run:
``` bash
uv venv
uv sync
```
This will create an in-project virtual environment and install all dependencies.
### Set environment variables
Copy `.env.example` to `.env` with `cp .env.example .env`.
Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file.
Set your desired database name, username, and password in the .env file.
To use password recovery and other email features, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and the email address you want to send emails from into the .env file. Note that you will need to [verify a domain through the Resend dashboard](https://resend.com/docs/dashboard/domains/introduction) to send emails from that domain.
### Start development database
To start the development database, run the following command in your terminal from the root directory:
``` bash
docker compose up -d
```
### Run the development server
Make sure the development database is running and tables and default permissions/roles are created first.
``` bash
uv run python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
Navigate to http://localhost:8000/
### Type check with ty
``` bash
uv run ty check .
```
## Developing with LLMs
``` {python}
#| echo: false
#| include: false
import re
from pathlib import Path
def extract_file_paths(quarto_yml_path):
"""
Extract href paths from _quarto.yml file.
Returns a list of .qmd file paths.
"""
with open(quarto_yml_path, 'r', encoding='utf-8') as f:
content = f.read()
# Find all href entries that point to .qmd files
pattern = r'^\s*-\s*href:\s*(.*?\.qmd)\s*$'
matches = re.findall(pattern, content, re.MULTILINE)
return matches
def process_qmd_content(file_path):
"""
Process a .qmd file by converting YAML frontmatter to markdown heading.
Returns the processed content as a string.
"""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Replace YAML frontmatter with markdown heading
pattern = r'^---\s*\ntitle:\s*"([^"]+)"\s*\n---'
processed_content = re.sub(pattern, r'# \1', content)
return processed_content
# Get the current working directory
base_dir = Path.cwd()
quarto_yml_path = base_dir / '_quarto.yml'
# Extract file paths from _quarto.yml
qmd_files = extract_file_paths(quarto_yml_path)
# Process each .qmd file and collect contents
processed_contents = []
for qmd_file in qmd_files:
file_path = base_dir / qmd_file
if file_path.exists():
processed_content = process_qmd_content(file_path)
processed_contents.append(processed_content)
# Concatenate all contents with double newline separator
final_content = '\n\n'.join(processed_contents)
# Ensure the output directory exists
output_dir = base_dir / 'docs' / 'static'
output_dir.mkdir(parents=True, exist_ok=True)
# Write the concatenated content to the output file
output_path = output_dir / 'documentation.txt'
with open(output_path, 'w', encoding='utf-8') as f:
f.write(final_content)
```
The `.cursor/rules` folder contains a set of AI rules for working on this codebase in the Cursor IDE. We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding for RAG.
## Contributing
Your contributions are welcome! See the [issues page](https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp/issues) for ideas. Fork the repository, create a new branch, make your changes, and submit a pull request.
## License
This project is created and maintained by [Promptly Technologies, LLC](https://promptlytechnologies.com/) and licensed under the MIT License. See the LICENSE file for more details.
---
title: "Architecture"
description: "Overview of the FastAPI webapp template architecture including data flow, database schema, project structure, and routing."
---
## Data flow
This application uses a **hybrid Post-Redirect-Get (PRG) + HTMX** architecture. Every mutating endpoint supports both paths simultaneously:
- **Non-HTMX path (PRG):** A standard browser form submission sends a POST request. On success the server issues a `303 See Other` redirect to a GET endpoint, which re-renders the full page with updated data. On error a full-page error template is returned.
- **HTMX path:** When the browser sends the `HX-Request: true` header (added automatically by [htmx.org](https://htmx.org)), the same POST endpoint detects the header via `utils/core/htmx.py:is_htmx_request()` and instead returns a `200` HTML partial that HTMX swaps into the relevant section of the page. On error a toast partial is returned and swapped into `#toast-container` via out-of-band (OOB) swap.
The HTMX rollout keeps the existing POST route contract intact — dedicated `PUT`/`PATCH`/`DELETE` routes may be introduced in a future iteration.
### PRG path
``` {python}
#| echo: false
#| include: false
from graphviz import Digraph
dot = Digraph()
dot.attr(rankdir='TB')
dot.attr('node', shape='box', style='rounded')
# Create client subgraph at top
with dot.subgraph(name='cluster_client') as client:
client.attr(label='Client')
client.attr(rank='topmost')
client.node('A', 'User submits form', fillcolor='lightblue', style='rounded,filled')
client.node('B', 'HTML/JS form validation', fillcolor='lightblue', style='rounded,filled')
# Create server subgraph below
with dot.subgraph(name='cluster_server') as server:
server.attr(label='Server')
server.node('C', 'FastAPI request validation in route signature', fillcolor='lightgreen', style='rounded,filled')
server.node('D', 'Business logic validation in route function body', fillcolor='lightgreen', style='rounded,filled')
server.node('E', 'Update database', fillcolor='lightgreen', style='rounded,filled')
server.node('F', 'Middleware error handler', fillcolor='lightgreen', style='rounded,filled')
server.node('G', 'Render error template', fillcolor='lightgreen', style='rounded,filled')
server.node('H', 'Redirect to GET endpoint', fillcolor='lightgreen', style='rounded,filled')
server.node('I', 'Fetch updated data', fillcolor='lightgreen', style='rounded,filled')
server.node('K', 'Re-render Jinja2 page template', fillcolor='lightgreen', style='rounded,filled')
with dot.subgraph(name='cluster_client_post') as client_post:
client_post.attr(label='Client')
client_post.attr(rank='bottommost')
client_post.node('J', 'Display rendered page', fillcolor='lightblue', style='rounded,filled')
# Add visible edges
dot.edge('A', 'B')
dot.edge('B', 'A')
dot.edge('B', 'C', label='POST Request to FastAPI endpoint')
dot.edge('C', 'D')
dot.edge('C', 'F', label='RequestValidationError')
dot.edge('D', 'E', label='Valid data')
dot.edge('D', 'F', label='Custom Validation Error')
dot.edge('E', 'H', label='Data updated')
dot.edge('H', 'I')
dot.edge('I', 'K')
dot.edge('K', 'J', label='Return HTML')
dot.edge('F', 'G')
dot.edge('G', 'J', label='Return HTML')
dot.render('static/data_flow_prg', format='png', cleanup=True)
```

### HTMX path
``` {python}
#| echo: false
#| include: false
from graphviz import Digraph
dot = Digraph()
dot.attr(rankdir='TB')
dot.attr('node', shape='box', style='rounded')
with dot.subgraph(name='cluster_client_htmx') as client:
client.attr(label='Client (HTMX)')
client.node('A2', 'User submits form', fillcolor='lightblue', style='rounded,filled')
client.node('B2', 'HTML/JS form validation', fillcolor='lightblue', style='rounded,filled')
with dot.subgraph(name='cluster_server_htmx') as server:
server.attr(label='Server')
server.node('C2', 'FastAPI request validation\n(HX-Request: true detected)', fillcolor='lightgreen', style='rounded,filled')
server.node('D2', 'Business logic validation', fillcolor='lightgreen', style='rounded,filled')
server.node('E2', 'Update database', fillcolor='lightgreen', style='rounded,filled')
server.node('F2', 'Exception handler\n(is_htmx_request branch)', fillcolor='lightgreen', style='rounded,filled')
server.node('G2', 'Render toast partial\n(#toast-container OOB swap)', fillcolor='lightgreen', style='rounded,filled')
server.node('H2', 'Render HTML partial\n(target element swap)', fillcolor='lightgreen', style='rounded,filled')
with dot.subgraph(name='cluster_client_post_htmx') as client_post:
client_post.attr(label='Client (HTMX)')
client_post.node('J2', 'HTMX swaps partial\ninto DOM', fillcolor='lightblue', style='rounded,filled')
client_post.node('K2', 'Toast displayed\n(no page reload)', fillcolor='lightyellow', style='rounded,filled')
dot.edge('A2', 'B2')
dot.edge('B2', 'A2')
dot.edge('B2', 'C2', label='POST + HX-Request: true')
dot.edge('C2', 'D2')
dot.edge('C2', 'F2', label='RequestValidationError')
dot.edge('D2', 'E2', label='Valid data')
dot.edge('D2', 'F2', label='Custom Validation Error')
dot.edge('E2', 'H2', label='Data updated')
dot.edge('H2', 'J2', label='200 HTML partial')
dot.edge('F2', 'G2')
dot.edge('G2', 'K2', label='422/400/401 toast partial')
dot.render('static/data_flow_htmx', format='png', cleanup=True)
```

The PRG path is preserved for all non-HTMX clients (e.g. browsers with JavaScript disabled, automated tests that do not send `HX-Request`). The HTMX path adds in-place partial updates and toast-based error handling on top of the same POST endpoints, with no change to the route URLs or form field contracts.
### Form validation and error handling
| Scenario | Non-HTMX (PRG) | HTMX |
|---|---|---|
| `RequestValidationError` (missing/invalid field) | Full-page error template, `422` | Toast partial via `#toast-container` OOB swap, `422` |
| Business logic error (`HTTPException`) | Full-page error template | Toast partial, `400`/`401`/`403`/`404` |
| Login failure (`CredentialsError`) | Full-page error template, `401` | Toast partial, `401` |
| Success | `303` redirect → GET → full page | `200` HTML partial swapped into target element |
Toast partials are rendered from `templates/base/partials/toast.html` and injected into the persistent `#toast-container` div in `base.html` using `hx-swap-oob="true"`. The toast is displayed using Bootstrap's toast component and can be dismissed by the user.
### HTMX request detection
All HTMX-aware endpoints use the `is_htmx_request()` helper from `utils/core/htmx.py`:
```python
def is_htmx_request(request: Request) -> bool:
return request.headers.get("HX-Request") == "true"
```
HTMX automatically adds the `HX-Request: true` header to every request it initiates. Non-HTMX form submissions (standard browser POSTs) do not include this header, so they follow the PRG path unchanged.
---
title: "Authentication"
description: "Guide to the FastAPI webapp template's authentication system including token-based auth, password recovery, and role-based access control."
---
## Security features
This template implements a comprehensive authentication system with security best practices:
1. **Token Security**:
- JWT-based with separate access/refresh tokens
- Strict expiry times (30 min access, 30 day refresh)
- Token type validation
- HTTP-only cookies
- Secure flag enabled
- SameSite=strict restriction
2. **Password Security**:
- Strong password requirements enforced
- Bcrypt hashing with random salt
- Password reset tokens are single-use
- Reset tokens have expiration
3. **Cookie Security**:
- HTTP-only prevents JavaScript access
- Secure flag ensures HTTPS only
- Strict SameSite prevents CSRF
4. **Error Handling**:
- Validation errors properly handled
- Security-related errors don't leak information
- Comprehensive error logging
The diagrams below show the main authentication flows.
## Registration and login flow
``` {python}
#| echo: false
#| include: false
from graphviz import Digraph
# Create graph for registration/login
auth = Digraph(name='auth_flow')
auth.attr(rankdir='TB')
auth.attr('node', shape='box', style='rounded')
# Client-side nodes
with auth.subgraph(name='cluster_client') as client:
client.attr(label='Client')
client.node('register_form', 'Submit registration', fillcolor='lightblue', style='rounded,filled')
client.node('login_form', 'Submit login', fillcolor='lightblue', style='rounded,filled')
client.node('store_cookies', 'Store secure cookies', fillcolor='lightblue', style='rounded,filled')
# Server-side nodes
with auth.subgraph(name='cluster_server') as server:
server.attr(label='Server')
# Registration path
server.node('validate_register', 'Validate registration data', fillcolor='lightgreen', style='rounded,filled')
server.node('hash_new', 'Hash new password', fillcolor='lightgreen', style='rounded,filled')
server.node('store_user', 'Store user in database', fillcolor='lightgreen', style='rounded,filled')
# Login path
server.node('validate_login', 'Validate login data', fillcolor='lightgreen', style='rounded,filled')
server.node('verify_password', 'Verify password hash', fillcolor='lightgreen', style='rounded,filled')
server.node('fetch_user', 'Fetch user from database', fillcolor='lightgreen', style='rounded,filled')
# Common path
server.node('generate_tokens', 'Generate JWT tokens', fillcolor='lightgreen', style='rounded,filled')
# Registration path
auth.edge('register_form', 'validate_register', 'POST /register')
auth.edge('validate_register', 'hash_new')
auth.edge('hash_new', 'store_user')
auth.edge('store_user', 'generate_tokens', 'Success')
# Login path
auth.edge('login_form', 'validate_login', 'POST /login')
auth.edge('validate_login', 'fetch_user')
auth.edge('fetch_user', 'verify_password')
auth.edge('verify_password', 'generate_tokens', 'Success')
# Common path
auth.edge('generate_tokens', 'store_cookies', 'Set-Cookie')
auth.render('static/auth_flow', format='png', cleanup=True)
```

## Password reset flow
``` {python}
#| echo: false
#| include: false
from graphviz import Digraph
# Create graph for password reset
reset = Digraph(name='reset_flow')
reset.attr(rankdir='TB')
reset.attr('node', shape='box', style='rounded')
# Client-side nodes - using light blue fill
reset.node('forgot', 'User submits forgot password form', fillcolor='lightblue', style='rounded,filled')
reset.node('reset', 'User submits reset password form', fillcolor='lightblue', style='rounded,filled')
reset.node('email_client', 'User clicks reset link', fillcolor='lightblue', style='rounded,filled')
# Server-side nodes - using light green fill
reset.node('validate', 'Validation', fillcolor='lightgreen', style='rounded,filled')
reset.node('token_gen', 'Generate reset token', fillcolor='lightgreen', style='rounded,filled')
reset.node('hash', 'Hash password', fillcolor='lightgreen', style='rounded,filled')
reset.node('email_server', 'Send email with Resend', fillcolor='lightgreen', style='rounded,filled')
reset.node('db', 'Database', shape='cylinder', fillcolor='lightgreen', style='filled')
# Add edges with labels
reset.edge('forgot', 'token_gen', 'POST')
reset.edge('token_gen', 'db', 'Store')
reset.edge('token_gen', 'email_server', 'Add email/token as URL parameter')
reset.edge('email_server', 'email_client')
reset.edge('email_client', 'reset', 'Set email/token as form input')
reset.edge('reset', 'validate', 'POST')
reset.edge('validate', 'hash')
reset.edge('hash', 'db', 'Update')
reset.render('static/reset_flow', format='png', cleanup=True)
```

---
title: "Installation"
description: "Step-by-step installation guide for the FastAPI webapp template including Dev Container setup, manual installation, and environment configuration."
---
## Install all development dependencies in a VSCode Dev Container
If you use VSCode with Docker to develop in a container, the following VSCode Dev Container configuration will install all development dependencies:
``` json
{
"name": "Python 3",
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm",
"postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz libwebp-dev && npm install bootstrap@5.3.3 && npm install -g sass && npm install -g gulp && uv venv && uv sync",
"features": {
"ghcr.io/va-h/devcontainers-features/uv:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {},
"ghcr.io/rocker-org/devcontainer-features/quarto-cli:1": {}
}
}
```
Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from `View > Command Palette`.
*IMPORTANT: If using this dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the `.env` file.*
## Install development dependencies manually
### uv
MacOS and Linux:
``` bash
wget -qO- https://astral.sh/uv/install.sh | sh
```
Windows:
``` bash
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```
See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for more information.
### Python
Install Python 3.12 or higher from either the official [downloads page](https://www.python.org/downloads/) or using uv:
``` bash
# Installs the latest version
uv python install
```
### Docker and Docker Compose
Install Docker Desktop and Docker Compose for your operating system by following the [instructions in the documentation](https://docs.docker.com/compose/install/).
### PostgreSQL headers
For Ubuntu/Debian:
``` bash
sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev
```
For macOS:
``` bash
brew install postgresql
```
For Windows:
- No installation required
### Python dependencies
From the root directory, run:
``` bash
uv venv
```
This will create an in-project virtual environment. Then run:
``` bash
uv sync
```
This will install all dependencies.
(Note: if `psycopg2` installation fails, you probably just need to install the PostgreSQL headers first and then try again.)
### Configure IDE
If you are using VSCode or Cursor as your IDE, you will need to select the `uv`-managed Python version as your interpreter for the project. Go to `View > Command Palette`, search for `Python: Select Interpreter`, and select the Python version labeled `('.venv':venv)`.
It is also recommended to install the [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) and [Quarto](https://marketplace.visualstudio.com/items?itemName=quarto.quarto) IDE extensions.
## Install documentation dependencies manually
### Quarto CLI
To render the project documentation, you will need to download and install the [Quarto CLI](https://quarto.org/docs/get-started/) for your operating system.
### Graphviz
Architecture diagrams in the documentation are rendered with [Graphviz](https://graphviz.org/).
For macOS:
``` bash
brew install graphviz
```
For Ubuntu/Debian:
``` bash
sudo apt update && sudo apt install -y graphviz
```
For Windows:
- Download and install from [Graphviz.org](https://graphviz.org/download/#windows)
## Set environment variables
Copy .env.example to .env with `cp .env.example .env`.
Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file.
Set your desired database name, username, and password in the .env file.
To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and sender email address into the .env file.
If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.)
## Start development database
To start the development database, run the following command in your terminal from the root directory:
``` bash
docker compose up -d
```
If at any point you change the environment variables in the .env file, you will need to stop the database service *and tear down the volume*:
``` bash
# Don't forget the -v flag to tear down the volume!
docker compose down -v
```
You may also need to restart the terminal session to pick up the new environment variables. You can also add the `--force-recreate` and `--build` flags to the startup command to ensure the container is rebuilt:
``` bash
docker compose up -d --force-recreate --build
```
## Run the development server
Before running the development server, make sure the development database is running and tables and default permissions/roles are created first. Then run the following command in your terminal from the root directory:
``` bash
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
Navigate to http://localhost:8000/.
(Note: If startup fails with a sqlalchemy/psycopg2 connection error, make sure that Docker Desktop and the database service are running and that the environment variables in the `.env` file are correctly populated, and then try again.)
## Type check with ty
``` bash
uv run ty check .
```
---
title: "Customization"
description: "Guide to customizing the FastAPI webapp template including development workflow, code style conventions, and project structure."
---
## Development workflow
### Dependency management with `uv`
The project uses `uv` to manage dependencies:
- Add new dependency: `uv add <dependency>`
- Add development dependency: `uv add --dev <dependency>`
- Remove dependency: `uv remove <dependency>`
- Update lock file: `uv lock`
- Install all dependencies: `uv sync`
- Install only production dependencies: `uv sync --no-dev`
- Upgrade dependencies: `uv lock --upgrade`
### IDE configuration
If you are using VSCode or Cursor as your IDE, you will need to select the `uv`-managed Python version as your interpreter for the project. Go to `View > Command Palette`, search for `Python: Select Interpreter`, and select the Python version labeled `('.venv':venv)`.
If your IDE does not automatically detect and display this option, you can manually select the interpreter by selecting "Enter interpreter path" and then navigating to the `.venv/bin/python` subfolder in your project directory.
### Extending the template
The `routers/core/` and `utils/core/` directories contain the core backend logic for the template.
Your custom Python backend code should go primarily in the `routers/app/` and `utils/app/` directories.
For the frontend, you will also need to develop custom Jinja2 templates in the `templates/` folder and add custom static assets in `static/`.
### Testing
The project uses Pytest for unit testing. It's highly recommended to write and run tests before committing code to ensure nothing is broken!
The following fixtures, defined in `tests/conftest.py`, are available in the test suite:
- `engine`: Creates a new SQLModel engine for the test database.
- `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state.
- `session`: Provides a session for database operations in tests.
- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken`, `EmailUpdateToken`, `User`, `Role`, `Organization`, and `Account` tables.
- `test_account`: Creates a test account with a predefined email and hashed password.
- `test_user`: Creates a test user in the database linked to the test account.
- `auth_client`: Provides a `TestClient` instance with access and refresh token cookies set, overriding the `get_session` dependency to use the `session` fixture.
- `unauth_client`: Provides a `TestClient` instance without authentication cookies set, overriding the `get_session` dependency to use the `session` fixture.
- `test_organization`: Creates a test organization for use in tests.
To run the tests, use these commands:
- Run all tests: `uv run pytest`
- Run tests in debug mode (includes logs and print statements in console output): `uv run pytest -s`
- Run particular test files by name: `uv run pytest <test_file_name>`
- Run particular tests by name: `uv run pytest -k <test_name>`
### Type checking with ty
The project uses type annotations and [ty](https://docs.astral.sh/ty/) for static type checking. To run ty, use this command from the root directory:
```bash
uv run ty check .
```
We find that static type checking is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that it requires you type annotate every variable, function, and method in your code base, so taking advantage of it requires a lifestyle change!
### Developing with LLMs
The `.cursor/rules` folder contains a set of AI rules for working on this codebase in the Cursor IDE. We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding for RAG.
## Application architecture
### Hybrid PRG + HTMX pattern
In this template, we use FastAPI to define the "API endpoints" of our application. An API endpoint is simply a URL that accepts user requests and returns responses. When a user visits a page, their browser sends what's called a "GET" request to an endpoint, and the server processes it (often querying a database), and returns a response (typically HTML). The browser renders the HTML, displaying the page.
We also create POST endpoints, which accept form submissions so the user can create, update, and delete data in the database. This template uses a **hybrid Post-Redirect-Get (PRG) + HTMX** approach for POST requests:
- **Non-HTMX (PRG):** When a form is submitted without HTMX, the server processes the data and returns a `303 See Other` redirect to a GET endpoint, which re-renders the full page with updated data.
- **HTMX:** When HTMX submits the same form, the server detects the `HX-Request: true` header and instead returns a `200` HTML partial that HTMX swaps into the relevant part of the page — no full-page reload needed.
Both paths use the same POST route URLs and form field contracts. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html) for more details.)
#### File structure
- FastAPI application entry point and homepage GET route: `main.py`
- Template FastAPI routes: `routers/core/`
- Account and authentication endpoints: `account.py`
- User profile management endpoints: `user.py`
- Organization management endpoints: `organization.py`
- Role management endpoints: `role.py`
- Dashboard page: `dashboard.py`
- Static pages (e.g., about, privacy policy, terms of service): `static_pages.py`
- Custom FastAPI routes for your app: `routers/app/`
- Jinja2 templates: `templates/`
- Static assets: `static/`
- Unit tests: `tests/`
- Test database configuration: `docker-compose.yml`
- Template helper functions: `utils/core/`
- Auth helpers: `auth.py`
- Database helpers: `db.py`
- FastAPI dependencies: `dependencies.py`
- Enums: `enums.py`
- Image helpers: `images.py`
- Database models: `models.py`
- Shared helper functions: `utils/`
- HTMX request detection: `htmx.py`
- Custom template helper functions for your app: `utils/app/`
- Exceptions: `exceptions/`
- HTTP exceptions: `http_exceptions.py`
- Other custom exceptions: `exceptions.py`
- Environment variables: `.env.example`, `.env`
- CI/CD configuration: `.github/`
- Project configuration: `pyproject.toml`
- Quarto documentation:
- README source: `index.qmd`
- Website source: `index.qmd` + `docs/`
- Configuration: `_quarto.yml` + `_environment`
- Rules for developing with LLMs in Cursor IDE: `.cursor/rules/`
Most everything else is auto-generated and should not be manually modified.
## Backend
### Code conventions
The GET route for the homepage is defined in the main entry point for the application, `main.py`. The entrypoint imports router modules from the `routers/core/` directory (for core/template logic) and `routers/app/` directory (for app-specific logic). In CRUD style, the core router modules are named after the resource they manage, e.g., `account.py` for account management. You should place your own endpoints in `routers/app/`.
We name our GET routes using the convention `read_<name>`, where `<name>` is the name of the resource, to indicate that they are read-only endpoints that do not modify the database. In POST routes that modify the database, you can use the `get_session` dependency as an argument to get a database session.
Routes that require authentication generally take the `get_authenticated_account` dependency as an argument. Unauthenticated GET routes generally take the `get_optional_user` dependency as an argument. If a route should *only* be seen by authenticated users (i.e., a login page), you can redirect to the dashboard if `get_optional_user` returns a `User` object.
### Context variables
Context refers to Python variables passed to a template to populate the HTML. In a FastAPI GET route, we can pass context to a template using the `templates.TemplateResponse` method, which takes the request and any context data as arguments. For example:
```python
@app.get("/welcome")
async def welcome(request: Request):
return templates.TemplateResponse(
request,
"welcome.html",
{"username": "Alice"}
)
```
In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{{ username }}}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML.
### Email templating
Password reset and other transactional emails are also handled through Jinja2 templates, located in the `templates/emails` directory. The email templates follow the same inheritance pattern as web templates, with `base_email.html` providing the common layout and styling.
Here's how the default password reset email template looks:

The email templates use inline CSS styles to ensure consistent rendering across email clients. Like web templates, they can receive context variables from the Python code (such as `reset_url` in the password reset template).
### Server-side form validation
Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads.
If a user-submitted form contains data that has the wrong number, names, or types of fields, Pydantic will raise a `RequestValidationError`, which is caught by middleware and converted into an HTTP 422 error response.
### Middleware exception handling
Middlewares—which process requests before they reach the route handlers and responses before they are sent back to the client—are defined in `main.py`. They are commonly used in web development for tasks such as error handling, authentication token validation, logging, and modifying request/response objects.
This template uses middlewares exclusively for global exception handling; they only affect requests that raise an exception. This allows for consistent error responses and centralized error logging. Middleware can catch exceptions raised during request processing and return appropriate HTTP responses.
Middleware functions are decorated with `@app.exception_handler(ExceptionType)` and are executed in the order they are defined in `main.py`, from most to least specific.
Here's a middleware for handling the `PasswordValidationError` exception, which returns a toast partial for HTMX requests or a full error page for non-HTMX requests:
```python
from utils.core.htmx import is_htmx_request
@app.exception_handler(PasswordValidationError)
async def password_validation_exception_handler(request: Request, exc: PasswordValidationError):
if is_htmx_request(request):
return templates.TemplateResponse(
request,
"base/partials/toast.html",
{"message": exc.detail, "level": "danger"},
status_code=422,
)
return templates.TemplateResponse(
request,
"errors/validation_error.html",
{
"status_code": 422,
"errors": {"error": exc.detail}
},
status_code=422,
)
```
## Database configuration and access with SQLModel
SQLModel is an Object-Relational Mapping (ORM) library that allows us to interact with our PostgreSQL database using Python classes instead of writing raw SQL. It combines the features of SQLAlchemy (a powerful database toolkit) with Pydantic's data validation.
### Models and relationships
Core database models are defined in `utils/core/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key core models are:
- `Account`: Represents a user account with email and password hash
- `User`: Represents a user profile with details like name and avatar; the email and password hash are stored in the related `Account` model
- `Organization`: Represents a company or team
- `Role`: Represents a set of permissions within an organization
- `Permission`: Represents specific actions a user can perform (defined by ValidPermissions enum)
- `PasswordResetToken`: Manages password reset functionality with expiration
- `EmailUpdateToken`: Manages email update confirmation functionality with expiration
Two additional models are used by SQLModel to manage many-to-many relationships; you generally will not need to interact with them directly:
- `UserRoleLink`: Maps users to their roles (many-to-many relationship)
- `RolePermissionLink`: Maps roles to their permissions (many-to-many relationship)
Here's an entity-relationship diagram (ERD) of the current core database schema, automatically generated from our SQLModel definitions:
```{python}
#| echo: false
#| warning: false
import sys
sys.path.append("..")
from utils.core.models import *
from utils.core.db import get_connection_url
from sqlmodel import create_engine
from sqlalchemy import MetaData
from sqlalchemy_schemadisplay import create_schema_graph
# Create the directed graph
graph = create_schema_graph(
engine=create_engine(get_connection_url()),
metadata=SQLModel.metadata,
show_datatypes=True,
show_indexes=True,
rankdir='TB',
concentrate=False
)
# Save the graph
graph.write_png('static/schema.png')
```

To extend the database schema, define your own models in `utils/app/models.py` and import them in `utils/core/db.py` to make sure they are included in the `metadata` object in the `create_all` function.
### Database helpers
Database operations are facilitated by helper functions in `utils/core/db.py` (for core logic) and `utils/app/` (for app-specific helpers). Key functions in the core utils include:
- `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`)
- `get_connection_url()`: Creates a database connection URL from environment variables in `.env`
- `get_session()`: Provides a database session for performing operations
To perform database operations in route handlers, inject the database session as a dependency (from `utils/core/db.py`):
```python
@app.get("/users")
async def get_users(session: Session = Depends(get_session)):
users = session.exec(select(User)).all()
return users
```
The session automatically handles transaction management, ensuring that database operations are atomic and consistent.
There is also a helper method on the `User` model that checks if a user has a specific permission for a given organization. Its first argument must be a `ValidPermissions` enum value (from `utils/core/models.py`), and its second argument must be an `Organization` object or an `int` representing an organization ID:
```python
permission = ValidPermissions.CREATE_ROLE
organization = session.exec(select(Organization).where(Organization.name == "Acme Inc.")).first()
user.has_permission(permission, organization)
```
You can add custom permission enum values to the `ValidPermissions` enum in `utils/core/enums.py` (below the core permissions section) and validate that users have the necessary permissions before allowing them to modify organization data resources.
### Cascade deletes
Cascade deletes (in which deleting a record from one table deletes related records from another table) can be handled at either the ORM level or the database level. This template handles cascade deletes at the ORM level, via SQLModel relationships. Inside a SQLModel `Relationship`, we set:
```python
sa_relationship_kwargs={
"cascade": "all, delete-orphan"
}
```
This tells SQLAlchemy to cascade all operations (e.g., `SELECT`, `INSERT`, `UPDATE`, `DELETE`) to the related table. Since this happens through the ORM, we need to be careful to do all our database operations through the ORM using supported syntax. That generally means loading database records into Python objects and then deleting those objects rather than deleting records in the database directly.
For example,
```python
session.exec(delete(Role))
```
will not trigger the cascade delete. Instead, we need to select the role objects and then delete them:
```python
for role in session.exec(select(Role)).all():
session.delete(role)
```
This is slower than deleting the records directly, but it makes [many-to-many relationships](https://sqlmodel.tiangolo.com/tutorial/many-to-many/create-models-with-link/#create-the-tables) much easier to manage.
## Frontend
### HTML templating with Jinja2
To generate the HTML pages to be returned from our GET routes, we use Jinja2 templates. Jinja2's hierarchical templates allow creating a base template (`templates/base.html`) that defines the overall layout of our web pages (e.g., where the header, body, and footer should go). Individual pages can then extend this base template. We can also template reusable components that can be injected into our layout or page templates.
With Jinja2, we can use the `{% block %}` tag to define content blocks, and the `{% extends %}` tag to extend a base template. We can also use the `{% include %}` tag to include a component in a parent template. See the [Jinja2 documentation on template inheritance](https://jinja.palletsprojects.com/en/stable/templates/#template-inheritance) for more details.
### Custom theming with Bootstrap
[Install Node.js](https://nodejs.org/en/download/) on your local machine if it is not there already.
Install `bootstrap`, `sass`, `gulp`, and `gulp-sass` in your project:
```bash
npm install --save-dev bootstrap sass gulp gulp-cli gulp-sass
```
This will create a `node_modules` folder, a `package-lock.json` file, and a `package.json` file in the root directory of the project.
Create an `scss` folder and a basic `scss/styles.scss` file:
```bash
mkdir scss