mirror of
https://github.com/fastapi/full-stack-fastapi-template.git
synced 2026-09-20 21:09:52 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf1548a983 | ||
|
|
c8f9069168 | ||
|
|
512e496e1f | ||
|
|
fd0ca44d77 | ||
|
|
af791805ac | ||
|
|
9a218cd74a | ||
|
|
d85803bf64 | ||
|
|
781b283849 | ||
|
|
c291285975 | ||
|
|
b1abd91ba7 | ||
|
|
e95e501951 | ||
|
|
111c5fad66 | ||
|
|
9d541082a3 | ||
|
|
8893c6807a | ||
|
|
1c5dd70308 | ||
|
|
b5a664d510 | ||
|
|
23b0ba9bad | ||
|
|
74cf9ed832 | ||
|
|
4256e83fec | ||
|
|
85cd14d474 | ||
|
|
5069df32ea | ||
|
|
f2035ef732 | ||
|
|
b0ccf1e80e | ||
|
|
de53c8fd35 | ||
|
|
375af70ee2 | ||
|
|
6c2122e37d | ||
|
|
2c28c6f7dc | ||
|
|
a5aff58850 | ||
|
|
060795204b | ||
|
|
1a30f30e51 | ||
|
|
dd15054da0 | ||
|
|
bda0325818 | ||
|
|
2c7e51505e | ||
|
|
0625bcc8b0 | ||
|
|
4632845a39 | ||
|
|
029e929253 | ||
|
|
8a213ddae7 | ||
|
|
66f444a63a | ||
|
|
9c8983e2a5 | ||
|
|
2d04828d6a | ||
|
|
0137c3f2c3 | ||
|
|
75c388ba1c | ||
|
|
d5b20172e4 | ||
|
|
9097b329d6 | ||
|
|
6a1c084f6e | ||
|
|
220bcb74aa | ||
|
|
34378100a8 | ||
|
|
e5cdc73405 | ||
|
|
96c1147ee7 | ||
|
|
d506ea4883 | ||
|
|
a8f1ba0e70 | ||
|
|
f8b7926d1c | ||
|
|
de92a0d9fa | ||
|
|
750d3d0bc6 | ||
|
|
1b4d46c0b3 | ||
|
|
97f28a5905 | ||
|
|
752fc77bed | ||
|
|
24ff71fdac | ||
|
|
af27db9a1f | ||
|
|
24db0e9e18 | ||
|
|
84908a6fdd | ||
|
|
546f18469c | ||
|
|
daae6e1434 | ||
|
|
78c699079e | ||
|
|
e80d92452e | ||
|
|
588dfd46e5 | ||
|
|
eb9275a2f8 | ||
|
|
c9e70d65c7 | ||
|
|
7d0d2a890e | ||
|
|
5b358ea6f4 | ||
|
|
1c82b25096 | ||
|
|
4d3d5e92c1 | ||
|
|
402ca985bc | ||
|
|
7d80b8534e | ||
|
|
183bcf5189 | ||
|
|
4e5284b884 | ||
|
|
7feaeb309e | ||
|
|
95580c0191 | ||
|
|
886d05d597 | ||
|
|
536b73011f | ||
|
|
349a7537dc | ||
|
|
4cd0d9e51a | ||
|
|
0def368739 | ||
|
|
d85ceb5025 | ||
|
|
f900e07cc3 | ||
|
|
9e9b3a786b | ||
|
|
b5a2b458cc | ||
|
|
abecc7782e | ||
|
|
9eaf8185f3 | ||
|
|
34d14a4e76 | ||
|
|
95e83b1352 | ||
|
|
a75858514a | ||
|
|
4214d8b1a8 | ||
|
|
dbfd760dcc | ||
|
|
9a807a12c5 | ||
|
|
0bc5df81a9 | ||
|
|
7aecfb098b | ||
|
|
e4153f7cbf | ||
|
|
18a28cdb1e | ||
|
|
779323df09 | ||
|
|
7cfe46bfdf | ||
|
|
3685fb6625 | ||
|
|
119e31fba2 | ||
|
|
6bc7fa47e3 | ||
|
|
33c75e4cab | ||
|
|
6d63f81979 | ||
|
|
77be72439c | ||
|
|
a586c1c05c | ||
|
|
8c6e31a8f7 | ||
|
|
8e4fd7c722 | ||
|
|
54de75638b | ||
|
|
49a94eaab4 | ||
|
|
14728b636c | ||
|
|
a727535488 | ||
|
|
95a7a61c8b | ||
|
|
70461bb937 | ||
|
|
4179f15185 | ||
|
|
787e79a463 | ||
|
|
67250999a5 | ||
|
|
2a6eeda629 | ||
|
|
6bb47f9c9c | ||
|
|
248d7d11e7 | ||
|
|
98d93cfee8 | ||
|
|
2a56db28b2 | ||
|
|
13c4678515 | ||
|
|
cd83fc10ca | ||
|
|
3beccfe716 | ||
|
|
61da1d1f8d | ||
|
|
9ec63eae4a | ||
|
|
7975107f31 | ||
|
|
f459c20a17 | ||
|
|
ee684d67db | ||
|
|
53f0cf4488 | ||
|
|
a54720d4a1 | ||
|
|
20731272ca | ||
|
|
1c1175eb50 | ||
|
|
3814f249b2 | ||
|
|
31e9f272f4 | ||
|
|
e6d4aead1c | ||
|
|
9af21b878e | ||
|
|
49e9c5216f | ||
|
|
5027f2effb | ||
|
|
e39f0630e0 | ||
|
|
5ee2fe25b2 | ||
|
|
469273290f | ||
|
|
a6c8ec01e5 | ||
|
|
085686b31f | ||
|
|
2097350645 | ||
|
|
5fcaab8bab | ||
|
|
6a2b002a8e | ||
|
|
4f22958c3f | ||
|
|
800669075f | ||
|
|
495b8dbbd5 | ||
|
|
38302d7492 | ||
|
|
8fefa3f3f2 | ||
|
|
34c8f8b78e | ||
|
|
c1b7479e7a | ||
|
|
33fa827e7e | ||
|
|
b6b79b424d | ||
|
|
baa12f641c | ||
|
|
7776e56880 | ||
|
|
d92bb18923 | ||
|
|
c81925a009 | ||
|
|
a2de9241ed | ||
|
|
94597a5021 | ||
|
|
9b1819a478 | ||
|
|
69e384859e | ||
|
|
32ebacfb42 | ||
|
|
f8516cd73b | ||
|
|
a2d50e26dd | ||
|
|
41b6dcedae | ||
|
|
39ecf9a093 | ||
|
|
baa742be6b | ||
|
|
13652b51ea | ||
|
|
03e021f30a | ||
|
|
bba8d07c0c | ||
|
|
37287deafe | ||
|
|
041377eb6d | ||
|
|
2fdd62ce0c | ||
|
|
8bf0025039 | ||
|
|
d784c02d36 | ||
|
|
ae3f6e3038 | ||
|
|
15e055f471 | ||
|
|
b42b147e98 | ||
|
|
6335787dc8 | ||
|
|
b16f3b4156 | ||
|
|
2d7d4e7768 | ||
|
|
fbaf2dbe9d | ||
|
|
1b2d94a887 | ||
|
|
4c63a663ac | ||
|
|
7005892795 | ||
|
|
64455e5c7b | ||
|
|
2f5ceec867 | ||
|
|
16afa0d363 | ||
|
|
40384c9deb | ||
|
|
aafa8ebfc9 | ||
|
|
5e2e8a9e6a | ||
|
|
917c7c898c | ||
|
|
b9ee37b8b4 | ||
|
|
465a5672d3 | ||
|
|
cf956e2b0b | ||
|
|
706bd53aa3 | ||
|
|
960beee281 | ||
|
|
9059faa6b0 | ||
|
|
aec52069da | ||
|
|
4a14d47647 | ||
|
|
56684e7714 | ||
|
|
3f6cda7bf4 | ||
|
|
97b8debf74 | ||
|
|
366eb587c1 | ||
|
|
a6c5dbe89d | ||
|
|
4628375e8c | ||
|
|
012a7d02b9 | ||
|
|
04e1a55f68 | ||
|
|
752c40cf5a | ||
|
|
f6c2e534c3 | ||
|
|
51295d09b7 | ||
|
|
db4532678c | ||
|
|
b5ac33fe0b | ||
|
|
083d3e5c3d | ||
|
|
f21b241e25 | ||
|
|
70bf8ba988 | ||
|
|
f3abf45ccd | ||
|
|
12788c2707 | ||
|
|
6f32450121 | ||
|
|
7003bf07c4 | ||
|
|
03dea29b79 | ||
|
|
42bf7e7cfe | ||
|
|
fa39625d80 | ||
|
|
0f289cf840 | ||
|
|
8b35efe999 | ||
|
|
7af1d80593 | ||
|
|
1b523bbc0d | ||
|
|
b40b0e0806 | ||
|
|
dcd8f1e641 | ||
|
|
13b2dac791 | ||
|
|
0d3bc003f1 | ||
|
|
1c6d656482 | ||
|
|
a252d2e678 | ||
|
|
fa8795141f | ||
|
|
2fd5ba65da | ||
|
|
5e1fa15e65 | ||
|
|
0162c1db74 | ||
|
|
3e6449ce0d | ||
|
|
dedaa07be0 | ||
|
|
a00382f3ca | ||
|
|
7fa809754c | ||
|
|
cd3d744186 | ||
|
|
72967929eb | ||
|
|
a5ad271f7c | ||
|
|
71d0e20af8 | ||
|
|
4e58251f76 | ||
|
|
e127163403 | ||
|
|
d40de23896 | ||
|
|
46e9e1926e | ||
|
|
43fac49a14 | ||
|
|
64eac127f0 | ||
|
|
3d2fffd138 | ||
|
|
a8fa3455ab | ||
|
|
3c1f7c4cdb | ||
|
|
2720308701 | ||
|
|
6c3323547b | ||
|
|
7a542cade6 | ||
|
|
e6c973060a | ||
|
|
4c65c018ac | ||
|
|
0d2a360857 | ||
|
|
12b2a95a58 | ||
|
|
de9661fc4f | ||
|
|
0564d65041 | ||
|
|
da603ebaac | ||
|
|
61e161bfb9 | ||
|
|
c3577c0526 | ||
|
|
ca37099b01 | ||
|
|
d74c794054 | ||
|
|
cdc5fba430 | ||
|
|
9ba793038e | ||
|
|
540766b961 | ||
|
|
1f332b89b4 | ||
|
|
6ab7a40cdd | ||
|
|
03bca13d17 | ||
|
|
e8418cb027 | ||
|
|
8ab41e3b24 | ||
|
|
6a49131003 | ||
|
|
97a4346c98 | ||
|
|
689d7105e1 | ||
|
|
7107f7e83a | ||
|
|
730c6e9ebb | ||
|
|
a0fe8a236f | ||
|
|
ca9cecf6c1 | ||
|
|
d0d06d18d7 | ||
|
|
458fddd772 | ||
|
|
608bb80106 | ||
|
|
9fe3a4d221 | ||
|
|
a45258f520 | ||
|
|
e3ead78f84 | ||
|
|
d5ee4a7276 | ||
|
|
352cded1cf | ||
|
|
f8c4e68472 | ||
|
|
4cab9e972f | ||
|
|
fe3bafc6f6 | ||
|
|
22b884a69e | ||
|
|
bbfa6ec46c | ||
|
|
9ceac2cfaf | ||
|
|
1690b598aa | ||
|
|
dd11319612 | ||
|
|
e75db847d8 | ||
|
|
23435b1053 | ||
|
|
c68ca3b989 | ||
|
|
54cd72e08d | ||
|
|
8574ea4397 | ||
|
|
fb2a02345b | ||
|
|
ab7c28c038 | ||
|
|
e0c80050f1 | ||
|
|
00e55ccabb | ||
|
|
565ddb61f7 | ||
|
|
a42f89b892 | ||
|
|
7201b88628 | ||
|
|
92460ee1fe | ||
|
|
3a5611aa7f | ||
|
|
fcad8f8270 | ||
|
|
87747c30cb | ||
|
|
e5236814e0 | ||
|
|
4fcaa97ac6 | ||
|
|
be7ff79aa7 | ||
|
|
88eade2231 | ||
|
|
63f167d462 | ||
|
|
11f2fc90d1 | ||
|
|
2e0db3f9b4 | ||
|
|
2059d2e271 | ||
|
|
ac9319d841 | ||
|
|
f21b70bd8c | ||
|
|
9f94fda46d | ||
|
|
7b0c7e8a1a | ||
|
|
eadcf5c9d5 | ||
|
|
e36bcf0e87 | ||
|
|
bdf5e26363 | ||
|
|
bd6f0d4edd | ||
|
|
650a33d98b | ||
|
|
44b2e67ced | ||
|
|
3826039f51 | ||
|
|
7d2307bb9a | ||
|
|
7f4befa0a9 | ||
|
|
d512a6a5f8 | ||
|
|
6a91475bf6 | ||
|
|
9db30ed04c | ||
|
|
c5b3d05aa3 | ||
|
|
31892f31d5 | ||
|
|
a5d93de972 | ||
|
|
c9a09fca7d | ||
|
|
9581560edb | ||
|
|
68726e4191 | ||
|
|
0f3423a818 | ||
|
|
b376e16adb | ||
|
|
7ce4aa0904 | ||
|
|
6250e97f4e | ||
|
|
ee316abde0 | ||
|
|
27a81ea023 |
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.venv/lib/python3.14/site-packages/fastapi/.agents/skills/fastapi
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"kind": "tool-skill",
|
||||||
|
"version": "0.0.19"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: library-skills
|
||||||
|
description: Use Library Skills to discover, install, refresh, repair, check, and manage agent skills from installed packages.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Library Skills
|
||||||
|
|
||||||
|
Use this skill when a project might benefit from agent skills bundled by its installed packages, or when existing Library Skills-managed symlinks are stale, broken, orphaned, or need to be checked.
|
||||||
|
|
||||||
|
Run commands from the project root.
|
||||||
|
|
||||||
|
Agents bundle their own skills by including an `.agents/skills` directory. More details in [Library Skills](https://library-skills.io).
|
||||||
|
|
||||||
|
## First-Time Setup
|
||||||
|
|
||||||
|
- Make sure project dependencies are installed first, for example with `uv sync` for Python projects or `npm install` / `bun install` for Node.js projects.
|
||||||
|
- Run `uvx library-skills` or `npx library-skills` to discover skills bundled by the installed packages and install selected skills interactively.
|
||||||
|
- Use `uvx library-skills --all` or `npx library-skills --all` only when all newly discovered skills should be installed without selecting individual skills.
|
||||||
|
- Use `uvx library-skills --tool-skill` or `npx library-skills --tool-skill` to copy this Library Skills tool skill into the project so future agents know how to discover, install, update, repair, and check skills.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- Run `uvx library-skills` or `npx library-skills` to discover package-provided skills, install selected new skills, and reconcile existing managed symlinks.
|
||||||
|
- Run `uvx library-skills list` or `npx library-skills list` to inspect discovered and installed skills.
|
||||||
|
- Run `uvx library-skills list --json` or `npx library-skills list --json` for machine-readable installed status.
|
||||||
|
- Run `uvx library-skills scan --json` or `npx library-skills scan --json` for discovery-only automation.
|
||||||
|
- Run `uvx library-skills --check` or `npx library-skills --check` to validate managed skill symlink state without changing files.
|
||||||
|
- Run `uvx library-skills --yes` or `npx library-skills --yes` to repair stale managed symlinks and remove orphaned managed symlinks non-interactively.
|
||||||
|
- Add `--claude` when `.claude/skills` should also be managed.
|
||||||
|
- Add `--skill NAME` to install a specific discovered skill by name.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
- Prefer rerunning `library-skills` over editing managed symlinks manually.
|
||||||
|
- If installed skill symlinks are broken, dependencies may not be installed yet. Try the project's normal install command first, such as `uv sync`, `npm install`, or `bun install`, then rerun `library-skills`.
|
||||||
|
- Do not delete or overwrite hand-authored skill directories.
|
||||||
|
- Library Skills only removes managed symlinks. It should not remove copied or hand-authored skill directories.
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.venv/lib/python3.14/site-packages/sqlmodel/.agents/skills/sqlmodel
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.venv/lib/python3.14/site-packages/fastapi/.agents/skills/fastapi
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"kind": "tool-skill",
|
||||||
|
"version": "0.0.19"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: library-skills
|
||||||
|
description: Use Library Skills to discover, install, refresh, repair, check, and manage agent skills from installed packages.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Library Skills
|
||||||
|
|
||||||
|
Use this skill when a project might benefit from agent skills bundled by its installed packages, or when existing Library Skills-managed symlinks are stale, broken, orphaned, or need to be checked.
|
||||||
|
|
||||||
|
Run commands from the project root.
|
||||||
|
|
||||||
|
Agents bundle their own skills by including an `.agents/skills` directory. More details in [Library Skills](https://library-skills.io).
|
||||||
|
|
||||||
|
## First-Time Setup
|
||||||
|
|
||||||
|
- Make sure project dependencies are installed first, for example with `uv sync` for Python projects or `npm install` / `bun install` for Node.js projects.
|
||||||
|
- Run `uvx library-skills` or `npx library-skills` to discover skills bundled by the installed packages and install selected skills interactively.
|
||||||
|
- Use `uvx library-skills --all` or `npx library-skills --all` only when all newly discovered skills should be installed without selecting individual skills.
|
||||||
|
- Use `uvx library-skills --tool-skill` or `npx library-skills --tool-skill` to copy this Library Skills tool skill into the project so future agents know how to discover, install, update, repair, and check skills.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- Run `uvx library-skills` or `npx library-skills` to discover package-provided skills, install selected new skills, and reconcile existing managed symlinks.
|
||||||
|
- Run `uvx library-skills list` or `npx library-skills list` to inspect discovered and installed skills.
|
||||||
|
- Run `uvx library-skills list --json` or `npx library-skills list --json` for machine-readable installed status.
|
||||||
|
- Run `uvx library-skills scan --json` or `npx library-skills scan --json` for discovery-only automation.
|
||||||
|
- Run `uvx library-skills --check` or `npx library-skills --check` to validate managed skill symlink state without changing files.
|
||||||
|
- Run `uvx library-skills --yes` or `npx library-skills --yes` to repair stale managed symlinks and remove orphaned managed symlinks non-interactively.
|
||||||
|
- Add `--claude` when `.claude/skills` should also be managed.
|
||||||
|
- Add `--skill NAME` to install a specific discovered skill by name.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
- Prefer rerunning `library-skills` over editing managed symlinks manually.
|
||||||
|
- If installed skill symlinks are broken, dependencies may not be installed yet. Try the project's normal install command first, such as `uv sync`, `npm install`, or `bun install`, then rerun `library-skills`.
|
||||||
|
- Do not delete or overwrite hand-authored skill directories.
|
||||||
|
- Library Skills only removes managed symlinks. It should not remove copied or hand-authored skill directories.
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.venv/lib/python3.14/site-packages/sqlmodel/.agents/skills/sqlmodel
|
||||||
@@ -1 +0,0 @@
|
|||||||
{{ _copier_answers|to_json -}}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
|
|
||||||
# Update the .env file with the answers from the .copier-answers.yml file
|
|
||||||
# without using Jinja2 templates in the .env file, this way the code works as is
|
|
||||||
# without needing Copier, but if Copier is used, the .env file will be updated
|
|
||||||
root_path = Path(__file__).parent.parent
|
|
||||||
answers_path = Path(__file__).parent / ".copier-answers.yml"
|
|
||||||
answers = json.loads(answers_path.read_text())
|
|
||||||
env_path = root_path / ".env"
|
|
||||||
env_content = env_path.read_text()
|
|
||||||
lines = []
|
|
||||||
for line in env_content.splitlines():
|
|
||||||
for key, value in answers.items():
|
|
||||||
upper_key = key.upper()
|
|
||||||
if line.startswith(f"{upper_key}="):
|
|
||||||
if " " in value:
|
|
||||||
content = f"{upper_key}={value!r}"
|
|
||||||
else:
|
|
||||||
content = f"{upper_key}={value}"
|
|
||||||
new_line = line.replace(line, content)
|
|
||||||
lines.append(new_line)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
lines.append(line)
|
|
||||||
env_path.write_text("\n".join(lines))
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
**/__pycache__
|
||||||
|
**/.venv
|
||||||
|
backend/app/frontend
|
||||||
|
backend/htmlcov
|
||||||
|
frontend/blob-report
|
||||||
|
frontend/dist
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/playwright-report
|
||||||
|
frontend/test-results
|
||||||
|
node_modules
|
||||||
@@ -1,45 +1,20 @@
|
|||||||
# Domain
|
# Enable development behavior for commands that import the app directly
|
||||||
# This would be set to the production domain with an env var on deployment
|
FASTAPI_ENV=development
|
||||||
# used by Traefik to transmit traffic and aqcuire TLS certificates
|
|
||||||
DOMAIN=localhost
|
|
||||||
# To test the local Traefik config
|
|
||||||
# DOMAIN=localhost.tiangolo.com
|
|
||||||
|
|
||||||
# Used by the backend to generate links in emails to the frontend
|
|
||||||
FRONTEND_HOST=http://localhost:5173
|
|
||||||
# In staging and production, set this env var to the frontend host, e.g.
|
|
||||||
# FRONTEND_HOST=https://dashboard.example.com
|
|
||||||
|
|
||||||
# Environment: local, staging, production
|
|
||||||
ENVIRONMENT=local
|
|
||||||
|
|
||||||
PROJECT_NAME="Full Stack FastAPI Project"
|
PROJECT_NAME="Full Stack FastAPI Project"
|
||||||
STACK_NAME=full-stack-fastapi-project
|
|
||||||
|
|
||||||
# Backend
|
|
||||||
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"
|
|
||||||
SECRET_KEY=changethis
|
SECRET_KEY=changethis
|
||||||
FIRST_SUPERUSER=admin@example.com
|
FIRST_SUPERUSER=admin@example.com
|
||||||
FIRST_SUPERUSER_PASSWORD=changethis
|
FIRST_SUPERUSER_PASSWORD=changethis
|
||||||
|
|
||||||
# Emails
|
# Emails
|
||||||
SMTP_HOST=
|
SMTP_HOST=localhost
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASSWORD=
|
|
||||||
EMAILS_FROM_EMAIL=info@example.com
|
EMAILS_FROM_EMAIL=info@example.com
|
||||||
SMTP_TLS=True
|
SMTP_TLS=False
|
||||||
SMTP_SSL=False
|
SMTP_PORT=1025
|
||||||
SMTP_PORT=587
|
|
||||||
|
|
||||||
# Postgres
|
# Postgres
|
||||||
POSTGRES_SERVER=localhost
|
POSTGRES_SERVER=localhost
|
||||||
POSTGRES_PORT=5432
|
|
||||||
POSTGRES_DB=app
|
POSTGRES_DB=app
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD=changethis
|
POSTGRES_PASSWORD=changethis
|
||||||
|
|
||||||
SENTRY_DSN=
|
|
||||||
|
|
||||||
# Configure these with your own Docker registry images
|
|
||||||
DOCKER_IMAGE_BACKEND=backend
|
|
||||||
DOCKER_IMAGE_FRONTEND=frontend
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
labels: [question]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for your interest in this project! 🚀
|
|
||||||
|
|
||||||
Please follow these instructions, fill every question, and do every step. 🙏
|
|
||||||
|
|
||||||
I'm asking this because answering questions and solving problems in GitHub is what consumes most of the time.
|
|
||||||
|
|
||||||
I end up not being able to add new features, fix bugs, review pull requests, etc. as fast as I wish because I have to spend too much time handling questions.
|
|
||||||
|
|
||||||
All that, on top of all the incredible help provided by a bunch of community members, that give a lot of their time to come here and help others.
|
|
||||||
|
|
||||||
That's a lot of work, but if more users came to help others like them just a little bit more, it would be much less effort for them (and you and me 😅).
|
|
||||||
|
|
||||||
By asking questions in a structured way (following this) it will be much easier to help you.
|
|
||||||
|
|
||||||
And there's a high chance that you will find the solution along the way and you won't even have to submit it and wait for an answer. 😎
|
|
||||||
|
|
||||||
As there are too many questions, I'll have to discard and close the incomplete ones. That will allow me (and others) to focus on helping people like you that follow the whole process and help us help you. 🤓
|
|
||||||
- type: checkboxes
|
|
||||||
id: checks
|
|
||||||
attributes:
|
|
||||||
label: First Check
|
|
||||||
description: Please confirm and check all the following options.
|
|
||||||
options:
|
|
||||||
- label: I added a very descriptive title here.
|
|
||||||
required: true
|
|
||||||
- label: I used the GitHub search to find a similar question and didn't find it.
|
|
||||||
required: true
|
|
||||||
- label: I searched in the documentation/README.
|
|
||||||
required: true
|
|
||||||
- label: I already searched in Google "How to do X" and didn't find any information.
|
|
||||||
required: true
|
|
||||||
- label: I already read and followed all the tutorial in the docs/README and didn't find an answer.
|
|
||||||
required: true
|
|
||||||
- type: checkboxes
|
|
||||||
id: help
|
|
||||||
attributes:
|
|
||||||
label: Commit to Help
|
|
||||||
description: |
|
|
||||||
After submitting this, I commit to one of:
|
|
||||||
|
|
||||||
* Read open questions until I find 2 where I can help someone and add a comment to help there.
|
|
||||||
* I already hit the "watch" button in this repository to receive notifications and I commit to help at least 2 people that ask questions in the future.
|
|
||||||
|
|
||||||
options:
|
|
||||||
- label: I commit to help with one of those options 👆
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: example
|
|
||||||
attributes:
|
|
||||||
label: Example Code
|
|
||||||
description: |
|
|
||||||
Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case.
|
|
||||||
|
|
||||||
If I (or someone) can copy it, run it, and see it right away, there's a much higher chance I (or someone) will be able to help you.
|
|
||||||
|
|
||||||
placeholder: |
|
|
||||||
Write your example code here.
|
|
||||||
render: Text
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: Description
|
|
||||||
description: |
|
|
||||||
What is the problem, question, or error?
|
|
||||||
|
|
||||||
Write a short description telling me what you are doing, what you expect to happen, and what is currently happening.
|
|
||||||
placeholder: |
|
|
||||||
* Open the browser and call the endpoint `/`.
|
|
||||||
* It returns a JSON with `{"message": "Hello World"}`.
|
|
||||||
* But I expected it to return `{"message": "Hello Morty"}`.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: dropdown
|
|
||||||
id: os
|
|
||||||
attributes:
|
|
||||||
label: Operating System
|
|
||||||
description: What operating system are you on?
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- Linux
|
|
||||||
- Windows
|
|
||||||
- macOS
|
|
||||||
- Other
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: os-details
|
|
||||||
attributes:
|
|
||||||
label: Operating System Details
|
|
||||||
description: You can add more details about your operating system here, in particular if you chose "Other".
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: input
|
|
||||||
id: python-version
|
|
||||||
attributes:
|
|
||||||
label: Python Version
|
|
||||||
description: |
|
|
||||||
What Python version are you using?
|
|
||||||
|
|
||||||
You can find the Python version with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python --version
|
|
||||||
```
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: Additional Context
|
|
||||||
description: Add any additional context information or screenshots you think are useful.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
github: [tiangolo]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
contact_links:
|
|
||||||
- name: Security Contact
|
|
||||||
about: Please report security vulnerabilities to security@tiangolo.com
|
|
||||||
- name: Question or Problem
|
|
||||||
about: Ask a question or ask about a problem in GitHub Discussions.
|
|
||||||
url: https://github.com/fastapi/full-stack-fastapi-template/discussions/categories/questions
|
|
||||||
- name: Feature Request
|
|
||||||
about: To suggest an idea or ask about a feature, please start with a question saying what you would like to achieve. There might be a way to do it already.
|
|
||||||
url: https://github.com/fastapi/full-stack-fastapi-template/discussions/categories/questions
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
name: Privileged
|
|
||||||
description: You are @tiangolo or he asked you directly to create an issue here. If not, check the other options. 👇
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for your interest in this project! 🚀
|
|
||||||
|
|
||||||
If you are not @tiangolo or he didn't ask you directly to create an issue here, please start the conversation in a [Question in GitHub Discussions](https://github.com/tiangolo/full-stack-fastapi-template/discussions/categories/questions) instead.
|
|
||||||
- type: checkboxes
|
|
||||||
id: privileged
|
|
||||||
attributes:
|
|
||||||
label: Privileged issue
|
|
||||||
description: Confirm that you are allowed to create an issue here.
|
|
||||||
options:
|
|
||||||
- label: I'm @tiangolo or he asked me directly to create an issue here.
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: content
|
|
||||||
attributes:
|
|
||||||
label: Issue Content
|
|
||||||
description: Add the content of the issue here.
|
|
||||||
+43
-12
@@ -4,43 +4,74 @@ updates:
|
|||||||
- package-ecosystem: github-actions
|
- package-ecosystem: github-actions
|
||||||
directory: /
|
directory: /
|
||||||
schedule:
|
schedule:
|
||||||
interval: daily
|
interval: "monthly"
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: ⬆
|
prefix: ⬆
|
||||||
labels: [dependencies, internal]
|
labels:
|
||||||
|
- "internal"
|
||||||
|
- "dependencies"
|
||||||
|
- "github_actions"
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
# Python uv
|
# Python uv
|
||||||
- package-ecosystem: uv
|
- package-ecosystem: uv
|
||||||
directory: /backend
|
directory: /
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
interval: "monthly"
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: ⬆
|
prefix: ⬆
|
||||||
labels: [dependencies, internal]
|
labels: [dependencies, internal]
|
||||||
# npm
|
groups:
|
||||||
- package-ecosystem: npm
|
python-packages:
|
||||||
directory: /frontend
|
patterns:
|
||||||
|
- "*"
|
||||||
|
# bun
|
||||||
|
- package-ecosystem: bun
|
||||||
|
directory: /
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
interval: "monthly"
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: ⬆
|
prefix: ⬆
|
||||||
labels: [dependencies, internal]
|
labels: [dependencies, internal]
|
||||||
ignore:
|
ignore:
|
||||||
- dependency-name: "@hey-api/openapi-ts"
|
- dependency-name: "@hey-api/openapi-ts"
|
||||||
|
groups:
|
||||||
|
npm-packages:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
# Docker
|
# Docker
|
||||||
- package-ecosystem: docker
|
- package-ecosystem: docker
|
||||||
directories:
|
directories:
|
||||||
- /backend
|
- /backend
|
||||||
- /frontend
|
- /frontend
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
interval: "monthly"
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: ⬆
|
prefix: ⬆
|
||||||
labels: [dependencies, internal]
|
groups:
|
||||||
|
docker:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
# Docker Compose
|
# Docker Compose
|
||||||
- package-ecosystem: docker-compose
|
- package-ecosystem: docker-compose
|
||||||
directory: /
|
directory: /
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
interval: "monthly"
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: ⬆
|
prefix: ⬆
|
||||||
labels: [dependencies, internal]
|
groups:
|
||||||
|
docker-compose:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
workflows:
|
||||||
|
- .github/workflows/pre-commit.yml
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
workflows:
|
||||||
|
- .github/workflows/bump-pre-commit-hooks.yml
|
||||||
|
- .github/workflows/prepare-release.yml
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
name: Add to Project
|
name: Add to Project
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||||
issues:
|
issues:
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
- reopened
|
- reopened
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
add-to-project:
|
add-to-project:
|
||||||
name: Add to project
|
name: Add to project
|
||||||
|
if: github.repository_owner == 'fastapi'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/add-to-project@v1.0.2
|
- uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2.0.0
|
||||||
with:
|
with:
|
||||||
project-url: https://github.com/orgs/fastapi/projects/2
|
project-url: https://github.com/orgs/fastapi/projects/2
|
||||||
github-token: ${{ secrets.PROJECTS_TOKEN }}
|
github-token: ${{ secrets.PROJECTS_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
name: Bump pre-commit hooks
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 12 1 * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump-pre-commit-hooks:
|
||||||
|
if: github.repository_owner == 'fastapi'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Dump GitHub context
|
||||||
|
env:
|
||||||
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
|
run: echo "$GITHUB_CONTEXT"
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version-file: ".python-version"
|
||||||
|
- name: Setup uv
|
||||||
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
|
with:
|
||||||
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
|
version: "0.11.18"
|
||||||
|
cache-dependency-glob: |
|
||||||
|
pyproject.toml
|
||||||
|
uv.lock
|
||||||
|
- name: Bump pre-commit hooks
|
||||||
|
run: uv run prek auto-update --freeze --cooldown-days 7
|
||||||
|
- name: Get PR Submit token
|
||||||
|
id: pr-submit
|
||||||
|
uses: tiangolo/pr-submit@d802fdf59bde80bc3eb8bd3259f4cbeec63de4aa # 0.0.1
|
||||||
|
- name: Create pull request
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.pr-submit.outputs.token }}
|
||||||
|
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "No pre-commit hook updates available"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "pr-submit[bot]"
|
||||||
|
git config user.email "pr-submit[bot]@users.noreply.github.com"
|
||||||
|
branch="bump-pre-commit-hooks"
|
||||||
|
git switch -C "$branch"
|
||||||
|
git add .pre-commit-config.yaml
|
||||||
|
git commit -m "⬆ Bump pre-commit hooks"
|
||||||
|
gh auth setup-git
|
||||||
|
git push --force origin "$branch"
|
||||||
|
if [ -z "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then
|
||||||
|
gh pr create \
|
||||||
|
--base "$BASE_BRANCH" \
|
||||||
|
--head "$branch" \
|
||||||
|
--title "⬆ Bump pre-commit hooks" \
|
||||||
|
--body "Bump pre-commit hook versions via \`prek auto-update --freeze --cooldown-days 7\`." \
|
||||||
|
--label internal \
|
||||||
|
--label dependencies \
|
||||||
|
--label pre-commit
|
||||||
|
else
|
||||||
|
echo "PR for \"$branch\" already open; branch updated in place."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Create Draft Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- closed
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-draft-release:
|
||||||
|
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: read
|
||||||
|
steps:
|
||||||
|
- name: Dump GitHub context
|
||||||
|
env:
|
||||||
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
|
run: echo "$GITHUB_CONTEXT"
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.repository.default_branch }}
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version-file: ".python-version"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
|
with:
|
||||||
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
|
version: "0.11.18"
|
||||||
|
- name: Extract release details
|
||||||
|
id: release-details
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version="$(uv run python scripts/prepare_release.py current-version)"
|
||||||
|
uv run python scripts/prepare_release.py release-notes > draft-release-notes.md
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Create draft release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
VERSION: ${{ steps.release-details.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gh release create "$VERSION" \
|
||||||
|
--draft \
|
||||||
|
--title "$VERSION" \
|
||||||
|
--notes-file draft-release-notes.md \
|
||||||
|
--target "$(git rev-parse HEAD)"
|
||||||
@@ -5,17 +5,19 @@ on:
|
|||||||
types:
|
types:
|
||||||
- published
|
- published
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
environment: production
|
||||||
# Do not deploy in the main repository, only in user projects
|
# Do not deploy in the main repository, only in user projects
|
||||||
if: github.repository_owner != 'fastapi'
|
if: github.repository_owner != 'fastapi'
|
||||||
runs-on:
|
runs-on:
|
||||||
- self-hosted
|
- self-hosted
|
||||||
- production
|
- production
|
||||||
env:
|
env:
|
||||||
ENVIRONMENT: production
|
DOMAIN: ${{ secrets.DOMAIN }}
|
||||||
DOMAIN: ${{ secrets.DOMAIN_PRODUCTION }}
|
|
||||||
STACK_NAME: ${{ secrets.STACK_NAME_PRODUCTION }}
|
|
||||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||||
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
||||||
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
||||||
@@ -27,6 +29,9 @@ jobs:
|
|||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- run: docker compose -f docker-compose.yml --project-name ${{ secrets.STACK_NAME_PRODUCTION }} build
|
with:
|
||||||
- run: docker compose -f docker-compose.yml --project-name ${{ secrets.STACK_NAME_PRODUCTION }} up -d
|
persist-credentials: false
|
||||||
|
- run: docker compose -f compose.yml -f compose.deploy.yml build
|
||||||
|
- run: docker compose -f compose.yml -f compose.deploy.yml run --rm backend bash scripts/prestart.sh
|
||||||
|
- run: docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
environment: staging
|
||||||
# Do not deploy in the main repository, only in user projects
|
# Do not deploy in the main repository, only in user projects
|
||||||
if: github.repository_owner != 'fastapi'
|
if: github.repository_owner != 'fastapi'
|
||||||
runs-on:
|
runs-on:
|
||||||
- self-hosted
|
- self-hosted
|
||||||
- staging
|
- staging
|
||||||
env:
|
env:
|
||||||
ENVIRONMENT: staging
|
DOMAIN: ${{ secrets.DOMAIN }}
|
||||||
DOMAIN: ${{ secrets.DOMAIN_STAGING }}
|
|
||||||
STACK_NAME: ${{ secrets.STACK_NAME_STAGING }}
|
|
||||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||||
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
||||||
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
||||||
@@ -27,6 +29,9 @@ jobs:
|
|||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- run: docker compose -f docker-compose.yml --project-name ${{ secrets.STACK_NAME_STAGING }} build
|
with:
|
||||||
- run: docker compose -f docker-compose.yml --project-name ${{ secrets.STACK_NAME_STAGING }} up -d
|
persist-credentials: false
|
||||||
|
- run: docker compose -f compose.yml -f compose.deploy.yml build
|
||||||
|
- run: docker compose -f compose.yml -f compose.deploy.yml run --rm backend bash scripts/prestart.sh
|
||||||
|
- run: docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
name: "Conflict detector"
|
name: "Conflict detector"
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
pull_request_target:
|
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||||
types: [synchronize]
|
types: [synchronize]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
main:
|
main:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Check if PRs have merge conflicts
|
- name: Check if PRs have merge conflicts
|
||||||
uses: eps1lon/actions-label-merge-conflict@v3
|
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||||
with:
|
with:
|
||||||
dirtyLabel: "conflicts"
|
dirtyLabel: "conflicts"
|
||||||
repoToken: "${{ secrets.GITHUB_TOKEN }}"
|
repoToken: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
name: Generate Client
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
generate-client:
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# For PRs from forks
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
# For PRs from the same repo
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
if: ( github.event_name != 'pull_request' || github.secret_source == 'Actions' )
|
|
||||||
with:
|
|
||||||
ref: ${{ github.head_ref }}
|
|
||||||
token: ${{ secrets.FULL_STACK_FASTAPI_TEMPLATE_REPO_TOKEN }}
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
- uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: "3.10"
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v7
|
|
||||||
with:
|
|
||||||
version: "0.4.15"
|
|
||||||
enable-cache: true
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
working-directory: frontend
|
|
||||||
- run: uv sync
|
|
||||||
working-directory: backend
|
|
||||||
- run: uv run bash scripts/generate-client.sh
|
|
||||||
env:
|
|
||||||
VIRTUAL_ENV: backend/.venv
|
|
||||||
SECRET_KEY: just-for-generating-client
|
|
||||||
POSTGRES_PASSWORD: just-for-generating-client
|
|
||||||
FIRST_SUPERUSER_PASSWORD: just-for-generating-client
|
|
||||||
- name: Add changes to git
|
|
||||||
run: |
|
|
||||||
git config --local user.email "github-actions@github.com"
|
|
||||||
git config --local user.name "github-actions"
|
|
||||||
git add frontend/src/client
|
|
||||||
# Same repo PRs
|
|
||||||
- name: Push changes
|
|
||||||
if: ( github.event_name != 'pull_request' || github.secret_source == 'Actions' )
|
|
||||||
run: |
|
|
||||||
git diff --staged --quiet || git commit -m "✨ Autogenerate frontend client"
|
|
||||||
git push
|
|
||||||
# Fork PRs
|
|
||||||
- name: Check changes
|
|
||||||
if: ( github.event_name == 'pull_request' && github.secret_source != 'Actions' )
|
|
||||||
run: |
|
|
||||||
git diff --staged --quiet || (echo "Changes detected in generated client, run scripts/generate-client.sh and commit the changes" && exit 1)
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
name: Guard Dependencies
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target: # zizmor: ignore[dangerous-triggers] -- This workflow only reads context.payload metadata, never checks out PR code
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- pyproject.toml
|
||||||
|
- uv.lock
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-author:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check if author is org member or allowed bot
|
||||||
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const pr = context.payload.pull_request;
|
||||||
|
const author = pr.user.login;
|
||||||
|
const assoc = pr.author_association;
|
||||||
|
|
||||||
|
const botAllowlist = new Set(['dependabot[bot]']);
|
||||||
|
const orgAuthorAssociations = new Set(['MEMBER', 'OWNER']);
|
||||||
|
|
||||||
|
const allowed =
|
||||||
|
botAllowlist.has(author) ||
|
||||||
|
(assoc != null && orgAuthorAssociations.has(assoc));
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.pull_request.number,
|
||||||
|
body: `This PR modifies dependency files (\`pyproject.toml\` or \`uv.lock\`), which is restricted to members of the **${context.repo.owner}** organization on GitHub.\n\nIf you need a dependency change, please [open a discussion](https://github.com/${context.repo.owner}/${context.repo.repo}/discussions/new) describing what you need and why.\n\nClosing this PR automatically.`
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.pulls.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: context.payload.pull_request.number,
|
||||||
|
state: 'closed'
|
||||||
|
});
|
||||||
|
|
||||||
|
core.setFailed('Dependency changes are restricted to organization members.');
|
||||||
|
} else {
|
||||||
|
console.log(`Author ${author} (author_association=${assoc}) is allowed to make dependency changes.`);
|
||||||
|
}
|
||||||
@@ -9,43 +9,26 @@ on:
|
|||||||
issues:
|
issues:
|
||||||
types:
|
types:
|
||||||
- labeled
|
- labeled
|
||||||
pull_request_target:
|
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||||
types:
|
types:
|
||||||
- labeled
|
- labeled
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions: {}
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
issue-manager:
|
issue-manager:
|
||||||
if: github.repository_owner == 'fastapi'
|
if: github.repository_owner == 'fastapi'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Dump GitHub context
|
- name: Dump GitHub context
|
||||||
env:
|
env:
|
||||||
GITHUB_CONTEXT: ${{ toJson(github) }}
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
run: echo "$GITHUB_CONTEXT"
|
run: echo "$GITHUB_CONTEXT"
|
||||||
- uses: tiangolo/issue-manager@0.6.0
|
- uses: tiangolo/issue-manager@dc846170c36eb62fb434b3d943b36399fe240fb5 # 0.8.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
config: >
|
|
||||||
{
|
|
||||||
"answered": {
|
|
||||||
"delay": 864000,
|
|
||||||
"message": "Assuming the original need was handled, this will be automatically closed now. But feel free to add more comments or create new issues or PRs."
|
|
||||||
},
|
|
||||||
"waiting": {
|
|
||||||
"delay": 2628000,
|
|
||||||
"message": "As this PR has been waiting for the original user for a while but seems to be inactive, it's now going to be closed. But if there's anyone interested, feel free to create a new PR.",
|
|
||||||
"reminder": {
|
|
||||||
"before": "P3D",
|
|
||||||
"message": "Heads-up: this will be closed in 3 days unless there's new activity."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"invalid": {
|
|
||||||
"delay": 0,
|
|
||||||
"message": "This was marked as invalid and will be closed now. If this is an error, please provide additional details."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
name: Labels
|
name: Labels
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
- synchronize
|
- synchronize
|
||||||
- reopened
|
- reopened
|
||||||
# For label-checker
|
|
||||||
- labeled
|
permissions: {}
|
||||||
- unlabeled
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
labeler:
|
labeler:
|
||||||
@@ -15,19 +14,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@v6
|
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||||
if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' }}
|
|
||||||
- run: echo "Done adding labels"
|
- run: echo "Done adding labels"
|
||||||
# Run this after labeler applied labels
|
|
||||||
check-labels:
|
|
||||||
needs:
|
|
||||||
- labeler
|
|
||||||
permissions:
|
|
||||||
pull-requests: read
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: docker://agilepathway/pull-request-label-checker:latest
|
|
||||||
with:
|
|
||||||
one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal
|
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
name: Latest Changes
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
types:
|
|
||||||
- closed
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
number:
|
|
||||||
description: PR number
|
|
||||||
required: true
|
|
||||||
debug_enabled:
|
|
||||||
description: "Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
latest-changes:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
pull-requests: read
|
|
||||||
steps:
|
|
||||||
- name: Dump GitHub context
|
|
||||||
env:
|
|
||||||
GITHUB_CONTEXT: ${{ toJson(github) }}
|
|
||||||
run: echo "$GITHUB_CONTEXT"
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
# To allow latest-changes to commit to the main branch
|
|
||||||
token: ${{ secrets.LATEST_CHANGES }}
|
|
||||||
- uses: tiangolo/latest-changes@0.4.1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
latest_changes_file: ./release-notes.md
|
|
||||||
latest_changes_header: "## Latest Changes"
|
|
||||||
end_regex: "^## "
|
|
||||||
debug_logs: true
|
|
||||||
label_header_prefix: "### "
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
name: Lint Backend
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint-backend:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: "3.10"
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v7
|
|
||||||
with:
|
|
||||||
version: "0.4.15"
|
|
||||||
enable-cache: true
|
|
||||||
- run: uv run bash scripts/lint.sh
|
|
||||||
working-directory: backend
|
|
||||||
@@ -5,9 +5,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
debug_enabled:
|
debug_enabled:
|
||||||
@@ -15,16 +12,23 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: 'false'
|
default: 'false'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
# Set job outputs to values from filter step
|
# Set job outputs to values from filter step
|
||||||
outputs:
|
outputs:
|
||||||
changed: ${{ steps.filter.outputs.changed }}
|
changed: ${{ steps.filter.outputs.changed }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
persist-credentials: false
|
||||||
# For pull requests it's not necessary to checkout the code but for the main branch it is
|
# For pull requests it's not necessary to checkout the code but for the main branch it is
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||||
id: filter
|
id: filter
|
||||||
with:
|
with:
|
||||||
filters: |
|
filters: |
|
||||||
@@ -32,14 +36,14 @@ jobs:
|
|||||||
- backend/**
|
- backend/**
|
||||||
- frontend/**
|
- frontend/**
|
||||||
- .env
|
- .env
|
||||||
- docker-compose*.yml
|
- compose*.yml
|
||||||
- .github/workflows/playwright.yml
|
- .github/workflows/playwright.yml
|
||||||
|
|
||||||
test-playwright:
|
test-playwright:
|
||||||
needs:
|
needs:
|
||||||
- changes
|
- changes
|
||||||
if: ${{ needs.changes.outputs.changed == 'true' }}
|
if: ${{ needs.changes.outputs.changed == 'true' }}
|
||||||
timeout-minutes: 60
|
timeout-minutes: 15
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -47,38 +51,40 @@ jobs:
|
|||||||
shardTotal: [4]
|
shardTotal: [4]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
with:
|
||||||
node-version: lts/*
|
persist-credentials: false
|
||||||
- uses: actions/setup-python@v6
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
bun-version: 1.3.12
|
||||||
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version-file: .python-version
|
||||||
- name: Setup tmate session
|
- name: Setup tmate session
|
||||||
uses: mxschmitt/action-tmate@v3
|
uses: mxschmitt/action-tmate@35b54afac29c97fb54faba5b513f8fbd1882f113 # v3.24
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
|
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
|
||||||
with:
|
with:
|
||||||
limit-access-to-actor: true
|
limit-access-to-actor: true
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: "0.4.15"
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
enable-cache: true
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
|
version: "0.11.18"
|
||||||
- run: uv sync
|
- run: uv sync
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
- run: npm ci
|
- run: bun ci
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- run: uv run bash scripts/generate-client.sh
|
- run: bash scripts/generate-client.sh
|
||||||
env:
|
|
||||||
VIRTUAL_ENV: backend/.venv
|
|
||||||
- run: docker compose build
|
- run: docker compose build
|
||||||
- run: docker compose down -v --remove-orphans
|
- run: docker compose down -v --remove-orphans
|
||||||
|
- run: docker compose run --rm backend bash scripts/prestart.sh
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: docker compose run --rm playwright npx playwright test --fail-on-flaky-tests --trace=retain-on-failure --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
run: docker compose run --rm playwright bunx playwright test --fail-on-flaky-tests --trace=retain-on-failure --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||||
- run: docker compose down -v --remove-orphans
|
- run: docker compose down -v --remove-orphans
|
||||||
- name: Upload blob report to GitHub Actions Artifacts
|
- name: Upload blob report to GitHub Actions Artifacts
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: blob-report-${{ matrix.shardIndex }}
|
name: blob-report-${{ matrix.shardIndex }}
|
||||||
path: frontend/blob-report
|
path: frontend/blob-report
|
||||||
@@ -92,25 +98,27 @@ jobs:
|
|||||||
# Merge reports after playwright-tests, even if some shards have failed
|
# Merge reports after playwright-tests, even if some shards have failed
|
||||||
if: ${{ !cancelled() && needs.changes.outputs.changed == 'true' }}
|
if: ${{ !cancelled() && needs.changes.outputs.changed == 'true' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
persist-credentials: false
|
||||||
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.12
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: bun ci
|
||||||
working-directory: frontend
|
|
||||||
- name: Download blob reports from GitHub Actions Artifacts
|
- name: Download blob reports from GitHub Actions Artifacts
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
path: frontend/all-blob-reports
|
path: frontend/all-blob-reports
|
||||||
pattern: blob-report-*
|
pattern: blob-report-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
- name: Merge into HTML Report
|
- name: Merge into HTML Report
|
||||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
run: bunx playwright merge-reports --reporter html ./all-blob-reports
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
- name: Upload HTML report
|
- name: Upload HTML report
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: html-report--attempt-${{ github.run_attempt }}
|
name: html-report--attempt-${{ github.run_attempt }}
|
||||||
path: frontend/playwright-report
|
path: frontend/playwright-report
|
||||||
@@ -123,9 +131,10 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- test-playwright
|
- test-playwright
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Decide whether the needed jobs succeeded or failed
|
- name: Decide whether the needed jobs succeeded or failed
|
||||||
uses: re-actors/alls-green@release/v1
|
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
|
||||||
with:
|
with:
|
||||||
jobs: ${{ toJSON(needs) }}
|
jobs: ${{ toJSON(needs) }}
|
||||||
allowed-skips: test-playwright
|
allowed-skips: test-playwright
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
name: pre-commit
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
env:
|
||||||
|
CAN_PUSH: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pre-commit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Dump GitHub context
|
||||||
|
env:
|
||||||
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
|
run: echo "$GITHUB_CONTEXT"
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
name: Checkout PR for own repo
|
||||||
|
if: env.CAN_PUSH == 'true'
|
||||||
|
with:
|
||||||
|
# To be able to commit it needs to fetch the head of the branch, not the
|
||||||
|
# merge commit
|
||||||
|
ref: ${{ github.head_ref }}
|
||||||
|
# And it needs the full history to be able to compute diffs
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
# pre-commit lite ci needs the default checkout configs to work
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
name: Checkout PR for fork
|
||||||
|
if: env.CAN_PUSH == 'false'
|
||||||
|
with:
|
||||||
|
# To be able to commit it needs the head branch of the PR, the remote one
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.12
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version-file: .python-version
|
||||||
|
- name: Setup uv
|
||||||
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
|
with:
|
||||||
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
|
version: "0.11.18"
|
||||||
|
cache-dependency-glob: |
|
||||||
|
requirements**.txt
|
||||||
|
pyproject.toml
|
||||||
|
uv.lock
|
||||||
|
- name: Install backend dependencies
|
||||||
|
run: uv sync --all-packages
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: bun ci
|
||||||
|
- name: Run prek - pre-commit
|
||||||
|
id: precommit
|
||||||
|
run: uv run prek run --from-ref origin/${GITHUB_BASE_REF} --to-ref HEAD --show-diff-on-failure
|
||||||
|
continue-on-error: true
|
||||||
|
- name: Check for changes
|
||||||
|
id: changes
|
||||||
|
run: |
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- name: Get PR Push token
|
||||||
|
id: pr-push
|
||||||
|
if: env.CAN_PUSH == 'true' && steps.changes.outputs.changed == 'true'
|
||||||
|
uses: tiangolo/pr-push@ff4e51a433de4c22bbf90597e069e8247b9203d2 # 0.0.1
|
||||||
|
- name: Commit and push changes
|
||||||
|
if: env.CAN_PUSH == 'true' && steps.changes.outputs.changed == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.pr-push.outputs.token }}
|
||||||
|
run: |
|
||||||
|
git config user.name "pr-push[bot]"
|
||||||
|
git config user.email "pr-push[bot]@users.noreply.github.com"
|
||||||
|
gh auth setup-git
|
||||||
|
git add -A
|
||||||
|
if git diff --staged --quiet; then
|
||||||
|
echo "No changes to commit"
|
||||||
|
else
|
||||||
|
git commit -m "🎨 Auto format and update with pre-commit"
|
||||||
|
git push
|
||||||
|
fi
|
||||||
|
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
||||||
|
if: env.CAN_PUSH == 'false'
|
||||||
|
with:
|
||||||
|
msg: 🎨 Auto format and update with pre-commit
|
||||||
|
- name: Error out on pre-commit errors
|
||||||
|
if: steps.precommit.outcome == 'failure'
|
||||||
|
run: exit 1
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/alls-green#why
|
||||||
|
pre-commit-alls-green: # This job does nothing and is only used for the branch protection
|
||||||
|
if: always()
|
||||||
|
needs:
|
||||||
|
- pre-commit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Dump GitHub context
|
||||||
|
env:
|
||||||
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
|
run: echo "$GITHUB_CONTEXT"
|
||||||
|
- name: Decide whether the needed jobs succeeded or failed
|
||||||
|
uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
|
||||||
|
with:
|
||||||
|
jobs: ${{ toJSON(needs) }}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
name: Prepare Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bump:
|
||||||
|
description: Release bump
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- patch
|
||||||
|
- minor
|
||||||
|
- major
|
||||||
|
date:
|
||||||
|
description: Release date in YYYY-MM-DD format. Defaults to today.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Dump GitHub context
|
||||||
|
env:
|
||||||
|
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||||
|
run: echo "$GITHUB_CONTEXT"
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version-file: ".python-version"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
|
with:
|
||||||
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
|
version: "0.11.18"
|
||||||
|
- name: Prepare release
|
||||||
|
env:
|
||||||
|
BUMP: ${{ inputs.bump }}
|
||||||
|
RELEASE_DATE: ${{ inputs.date }}
|
||||||
|
run: uv run python scripts/prepare_release.py prepare "$BUMP" --date "$RELEASE_DATE"
|
||||||
|
- name: Get release version
|
||||||
|
id: release-version
|
||||||
|
run: |
|
||||||
|
version="$(uv run python scripts/prepare_release.py current-version)"
|
||||||
|
echo "$version"
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Get PR Submit token
|
||||||
|
id: pr-submit
|
||||||
|
uses: tiangolo/pr-submit@d802fdf59bde80bc3eb8bd3259f4cbeec63de4aa # 0.0.1
|
||||||
|
- name: Create release pull request
|
||||||
|
env:
|
||||||
|
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
|
GH_TOKEN: ${{ steps.pr-submit.outputs.token }}
|
||||||
|
VERSION: ${{ steps.release-version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
branch="release-${VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
git config user.name "pr-submit[bot]"
|
||||||
|
git config user.email "pr-submit[bot]@users.noreply.github.com"
|
||||||
|
git switch -c "$branch"
|
||||||
|
git add release-notes.md
|
||||||
|
git commit -m "🔖 Release version ${VERSION}"
|
||||||
|
gh auth setup-git
|
||||||
|
git push --set-upstream origin "$branch"
|
||||||
|
gh label create release \
|
||||||
|
--description "Release preparation" \
|
||||||
|
--color ededed \
|
||||||
|
--force
|
||||||
|
gh pr create \
|
||||||
|
--base "$BASE_BRANCH" \
|
||||||
|
--head "$branch" \
|
||||||
|
--title "🔖 Release version ${VERSION}" \
|
||||||
|
--body "Prepare release ${VERSION}." \
|
||||||
|
--label release
|
||||||
@@ -1,35 +1,49 @@
|
|||||||
name: Smokeshow
|
name: Smokeshow
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run: # zizmor: ignore[dangerous-triggers]
|
||||||
workflows: [Test Backend]
|
workflows: [Test Backend]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
smokeshow:
|
smokeshow:
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
|
contents: read
|
||||||
statuses: write
|
statuses: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: actions/setup-python@v6
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
persist-credentials: false
|
||||||
- run: pip install smokeshow
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
- uses: actions/download-artifact@v6
|
with:
|
||||||
|
python-version-file: .python-version
|
||||||
|
- name: Setup uv
|
||||||
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
|
with:
|
||||||
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
|
version: "0.11.18"
|
||||||
|
cache-dependency-glob: |
|
||||||
|
pyproject.toml
|
||||||
|
uv.lock
|
||||||
|
- run: uv sync --all-packages --no-dev --group github-actions
|
||||||
|
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
name: coverage-html
|
name: coverage-html
|
||||||
path: backend/htmlcov
|
path: backend/htmlcov
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run-id: ${{ github.event.workflow_run.id }}
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
- run: smokeshow upload backend/htmlcov
|
- run: uv run smokeshow upload backend/htmlcov
|
||||||
env:
|
env:
|
||||||
SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage}
|
SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage}
|
||||||
SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 90
|
SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 90
|
||||||
SMOKESHOW_GITHUB_CONTEXT: coverage
|
SMOKESHOW_GITHUB_CONTEXT: coverage
|
||||||
SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
SMOKESHOW_AUTH_KEY: ${{ secrets.SMOKESHOW_AUTH_KEY }}
|
SMOKESHOW_AUTH_KEY: ${{ secrets.SMOKESHOW_AUTH_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||||
|
|||||||
@@ -5,25 +5,28 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
types:
|
permissions:
|
||||||
- opened
|
contents: read
|
||||||
- synchronize
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-backend:
|
test-backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version-file: .python-version
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: "0.4.15"
|
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||||
enable-cache: true
|
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||||
|
version: "0.11.18"
|
||||||
- run: docker compose down -v --remove-orphans
|
- run: docker compose down -v --remove-orphans
|
||||||
- run: docker compose up -d db mailcatcher
|
- run: docker compose up -d db mailcatcher
|
||||||
- name: Migrate DB
|
- name: Migrate DB
|
||||||
@@ -34,8 +37,11 @@ jobs:
|
|||||||
working-directory: backend
|
working-directory: backend
|
||||||
- run: docker compose down -v --remove-orphans
|
- run: docker compose down -v --remove-orphans
|
||||||
- name: Store coverage files
|
- name: Store coverage files
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: coverage-html
|
name: coverage-html
|
||||||
path: backend/htmlcov
|
path: backend/htmlcov
|
||||||
include-hidden-files: true
|
include-hidden-files: true
|
||||||
|
- name: Coverage report
|
||||||
|
run: uv run coverage report --fail-under=90
|
||||||
|
working-directory: backend
|
||||||
|
|||||||
@@ -5,22 +5,25 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request:
|
pull_request:
|
||||||
types:
|
permissions:
|
||||||
- opened
|
contents: read
|
||||||
- synchronize
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
test-docker-compose:
|
test-docker-compose:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- run: docker compose build
|
- run: docker compose build
|
||||||
- run: docker compose down -v --remove-orphans
|
- run: docker compose down -v --remove-orphans
|
||||||
- run: docker compose up -d --wait backend frontend adminer
|
- run: docker compose run --rm backend bash scripts/prestart.sh
|
||||||
|
- run: docker compose up -d --wait backend adminer
|
||||||
- name: Test backend is up
|
- name: Test backend is up
|
||||||
run: curl http://localhost:8000/api/v1/utils/health-check
|
run: curl http://localhost:8000/api/v1/utils/health-check
|
||||||
- name: Test frontend is up
|
- name: Test frontend is up
|
||||||
run: curl http://localhost:5173
|
run: curl http://localhost:8000
|
||||||
- run: docker compose down -v --remove-orphans
|
- run: docker compose down -v --remove-orphans
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: Zizmor
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
zizmor:
|
||||||
|
name: Run zizmor
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Run zizmor
|
||||||
|
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
||||||
|
with:
|
||||||
|
advanced-security: ${{ github.repository_owner == 'fastapi' }}
|
||||||
|
annotations: ${{ github.repository_owner != 'fastapi' }}
|
||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
.vscode
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
node_modules/
|
node_modules/
|
||||||
|
backend/app/frontend/
|
||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
|
|||||||
+56
-12
@@ -2,7 +2,7 @@
|
|||||||
# See https://pre-commit.com/hooks.html for more hooks
|
# See https://pre-commit.com/hooks.html for more hooks
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.4.0
|
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
@@ -13,26 +13,70 @@ repos:
|
|||||||
exclude: |
|
exclude: |
|
||||||
(?x)^(
|
(?x)^(
|
||||||
frontend/src/client/.*|
|
frontend/src/client/.*|
|
||||||
backend/app/email-templates/build/.*
|
backend/app/email-templates/.*
|
||||||
)$
|
)$
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
exclude: ^frontend/src/client/.*
|
exclude: ^frontend/src/client/.*
|
||||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
- repo: https://github.com/crate-ci/typos
|
||||||
rev: v0.2.2
|
rev: bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # frozen: v1.48.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: typos
|
||||||
args:
|
args: [--force-exclude]
|
||||||
- --fix
|
|
||||||
- id: ruff-format
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: local-biome-check
|
- id: local-biome-check
|
||||||
name: biome check
|
name: biome check
|
||||||
entry: bash -c 'cd frontend && npm run lint'
|
entry: npm run lint
|
||||||
language: system
|
language: system
|
||||||
types: [text]
|
types: [text]
|
||||||
files: ^frontend/
|
files: ^frontend/
|
||||||
|
|
||||||
ci:
|
- id: local-ruff-check
|
||||||
autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks
|
name: ruff check
|
||||||
autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate
|
entry: uv run ruff check --force-exclude --fix --exit-non-zero-on-fix
|
||||||
|
require_serial: true
|
||||||
|
language: unsupported
|
||||||
|
types: [python]
|
||||||
|
|
||||||
|
- id: local-ruff-format
|
||||||
|
name: ruff format
|
||||||
|
entry: uv run ruff format --force-exclude --exit-non-zero-on-format
|
||||||
|
require_serial: true
|
||||||
|
language: unsupported
|
||||||
|
types: [python]
|
||||||
|
|
||||||
|
- id: local-mypy
|
||||||
|
name: mypy check
|
||||||
|
entry: uv run mypy backend/app
|
||||||
|
require_serial: true
|
||||||
|
language: unsupported
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
- id: local-ty
|
||||||
|
name: ty check
|
||||||
|
entry: uv run ty check backend/app
|
||||||
|
require_serial: true
|
||||||
|
language: unsupported
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
- id: generate-frontend-sdk
|
||||||
|
name: Generate Frontend SDK
|
||||||
|
entry: bash ./scripts/generate-client.sh
|
||||||
|
pass_filenames: false
|
||||||
|
language: unsupported
|
||||||
|
files: ^backend/app/.*\.py$|^backend/pyproject\.toml$|^uv\.lock$|^frontend/openapi-ts\.config\.ts$|^scripts/generate-client\.sh$
|
||||||
|
|
||||||
|
- id: add-release-date
|
||||||
|
language: unsupported
|
||||||
|
name: add date to latest release header
|
||||||
|
entry: uv run python scripts/add_latest_release_date.py
|
||||||
|
files: ^release-notes\.md$
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
- id: zizmor
|
||||||
|
name: zizmor
|
||||||
|
language: python
|
||||||
|
entry: uv run zizmor .
|
||||||
|
files: ^\.github\/workflows\/
|
||||||
|
require_serial: true
|
||||||
|
pass_filenames: false
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"FastAPILabs.fastapi-vscode",
|
||||||
|
"astral-sh.ty",
|
||||||
|
"biomejs.biome",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"charliermarsh.ruff",
|
||||||
|
"docker.docker",
|
||||||
|
"github.vscode-github-actions",
|
||||||
|
"ms-playwright.playwright",
|
||||||
|
"ms-python.python",
|
||||||
|
"tombi-toml.tombi"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to the Full Stack FastAPI Template! 🙇
|
||||||
|
|
||||||
|
## Discussions First
|
||||||
|
|
||||||
|
For **big changes** (new features, architectural changes, significant refactoring), please start by opening a [GitHub Discussion](https://github.com/fastapi/full-stack-fastapi-template/discussions) first. This allows the community and maintainers to provide feedback on the approach before you invest significant time in implementation.
|
||||||
|
|
||||||
|
For small, straightforward changes, you can go directly to a Pull Request without starting a discussion first. This includes:
|
||||||
|
|
||||||
|
- Typos and grammatical fixes
|
||||||
|
- Small reproducible bug fixes
|
||||||
|
- Fixing lint warnings or type errors
|
||||||
|
- Minor code improvements (e.g., removing unused code)
|
||||||
|
|
||||||
|
Note that PRs from non-team members are not allowed to modify `pyproject.toml` or `uv.lock`, to prevent supply chain risk.
|
||||||
|
If you would like to add a new dependency, create a new [Discussion](https://github.com/fastapi/full-stack-fastapi-template/discussions) to explain why.
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
For detailed instructions on setting up your development environment, running the stack, linting, pre-commit hooks, and more, see the [Development Guide](development.md).
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
When submitting a pull request:
|
||||||
|
|
||||||
|
1. Make sure all tests pass before submitting.
|
||||||
|
2. Keep PRs focused on a single change.
|
||||||
|
3. Update tests if you're changing functionality.
|
||||||
|
4. Reference any related issues in your PR description.
|
||||||
|
|
||||||
|
## Automated Code and AI
|
||||||
|
|
||||||
|
You are encouraged to use all the tools you want to do your work and contribute as efficiently as possible, this includes AI (LLM) tools, etc. Nevertheless, contributions should have meaningful human intervention, judgement, context, etc.
|
||||||
|
|
||||||
|
If the **human effort** put in a PR, e.g. writing LLM prompts, is **less** than the **effort we would need to put** to **review it**, please **don't** submit the PR.
|
||||||
|
|
||||||
|
Think of it this way: we can already write LLM prompts or run automated tools ourselves, and that would be faster than reviewing external PRs.
|
||||||
|
|
||||||
|
### Closing Automated and AI PRs
|
||||||
|
|
||||||
|
If we see PRs that seem AI generated or automated in similar ways, we'll flag them and close them.
|
||||||
|
|
||||||
|
The same applies to comments and descriptions, please don't copy paste the content generated by an LLM.
|
||||||
|
|
||||||
|
### Human Effort Denial of Service
|
||||||
|
|
||||||
|
Using automated tools and AI to submit PRs or comments that we have to carefully review and handle would be the equivalent of a [Denial-of-service attack](https://en.wikipedia.org/wiki/Denial-of-service_attack) on our human effort.
|
||||||
|
|
||||||
|
It would be very little effort from the person submitting the PR (an LLM prompt) that generates a large amount of effort on our side (carefully reviewing code).
|
||||||
|
|
||||||
|
Please don't do that.
|
||||||
|
|
||||||
|
We'll need to block accounts that spam us with repeated automated PRs or comments.
|
||||||
|
|
||||||
|
### Use Tools Wisely
|
||||||
|
|
||||||
|
As Uncle Ben said:
|
||||||
|
|
||||||
|
> With great ~~power~~ **tools** comes great responsibility.
|
||||||
|
|
||||||
|
Avoid inadvertently doing harm.
|
||||||
|
|
||||||
|
You have amazing tools at hand, use them wisely to help effectively.
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
If you have questions about contributing, feel free to open a [GitHub Discussion](https://github.com/fastapi/full-stack-fastapi-template/discussions).
|
||||||
@@ -11,36 +11,37 @@
|
|||||||
- 🔍 [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management.
|
- 🔍 [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management.
|
||||||
- 💾 [PostgreSQL](https://www.postgresql.org) as the SQL database.
|
- 💾 [PostgreSQL](https://www.postgresql.org) as the SQL database.
|
||||||
- 🚀 [React](https://react.dev) for the frontend.
|
- 🚀 [React](https://react.dev) for the frontend.
|
||||||
|
- 🧩 Built into the backend image and served by FastAPI on the same domain as the API.
|
||||||
- 💃 Using TypeScript, hooks, [Vite](https://vitejs.dev), and other parts of a modern frontend stack.
|
- 💃 Using TypeScript, hooks, [Vite](https://vitejs.dev), and other parts of a modern frontend stack.
|
||||||
- 🎨 [Tailwind CSS](https://tailwindcss.com) and [shadcn/ui](https://ui.shadcn.com) for the frontend components.
|
- 🎨 [Tailwind CSS](https://tailwindcss.com) and [shadcn/ui](https://ui.shadcn.com) for the frontend components.
|
||||||
- 🤖 An automatically generated frontend client.
|
- 🤖 An automatically generated frontend client.
|
||||||
- 🧪 [Playwright](https://playwright.dev) for End-to-End testing.
|
- 🧪 [Playwright](https://playwright.dev) for End-to-End testing.
|
||||||
- 🦇 Dark mode support.
|
- 🦇 Dark mode support.
|
||||||
- 🐋 [Docker Compose](https://www.docker.com) for development and production.
|
- 🐋 [Docker Compose](https://www.docker.com) for local services and deployment.
|
||||||
- 🔒 Secure password hashing by default.
|
- 🔒 Secure password hashing by default.
|
||||||
- 🔑 JWT (JSON Web Token) authentication.
|
- 🔑 JWT (JSON Web Token) authentication.
|
||||||
- 📫 Email based password recovery.
|
- 📫 Email based password recovery.
|
||||||
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
|
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
|
||||||
- ✅ Tests with [Pytest](https://pytest.org).
|
- ✅ Tests with [Pytest](https://pytest.org).
|
||||||
- 📞 [Traefik](https://traefik.io) as a reverse proxy / load balancer.
|
- 📞 [Traefik](https://traefik.io) as a reverse proxy / load balancer.
|
||||||
- 🚢 Deployment instructions using Docker Compose, including how to set up a frontend Traefik proxy to handle automatic HTTPS certificates.
|
- 🚢 Deployment instructions using Docker Compose with automatic HTTPS provided by Traefik.
|
||||||
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
|
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
|
||||||
|
|
||||||
### Dashboard Login
|
### Dashboard Login
|
||||||
|
|
||||||
[](https://github.com/fastapi/full-stack-fastapi-template)
|
[](https://github.com/fastapi/full-stack-fastapi-template)
|
||||||
|
|
||||||
### Dashboard - Admin
|
### Dashboard - Admin
|
||||||
|
|
||||||
[](https://github.com/fastapi/full-stack-fastapi-template)
|
[](https://github.com/fastapi/full-stack-fastapi-template)
|
||||||
|
|
||||||
### Dashboard - Items
|
### Dashboard - Items
|
||||||
|
|
||||||
[](https://github.com/fastapi/full-stack-fastapi-template)
|
[](https://github.com/fastapi/full-stack-fastapi-template)
|
||||||
|
|
||||||
### Dashboard - Dark Mode
|
### Dashboard - Dark Mode
|
||||||
|
|
||||||
[](https://github.com/fastapi/full-stack-fastapi-template)
|
[](https://github.com/fastapi/full-stack-fastapi-template)
|
||||||
|
|
||||||
### Interactive API Documentation
|
### Interactive API Documentation
|
||||||
|
|
||||||
@@ -146,66 +147,6 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|||||||
|
|
||||||
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
||||||
|
|
||||||
## How To Use It - Alternative With Copier
|
|
||||||
|
|
||||||
This repository also supports generating a new project using [Copier](https://copier.readthedocs.io).
|
|
||||||
|
|
||||||
It will copy all the files, ask you configuration questions, and update the `.env` files with your answers.
|
|
||||||
|
|
||||||
### Install Copier
|
|
||||||
|
|
||||||
You can install Copier with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install copier
|
|
||||||
```
|
|
||||||
|
|
||||||
Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipx install copier
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: If you have `pipx`, installing copier is optional, you could run it directly.
|
|
||||||
|
|
||||||
### Generate a Project With Copier
|
|
||||||
|
|
||||||
Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`.
|
|
||||||
|
|
||||||
Go to the directory that will be the parent of your project, and run the command with your project's name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
|
|
||||||
```
|
|
||||||
|
|
||||||
If you have `pipx` and you didn't install `copier`, you can run it directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files.
|
|
||||||
|
|
||||||
### Input Variables
|
|
||||||
|
|
||||||
Copier will ask you for some data, you might want to have at hand before generating the project.
|
|
||||||
|
|
||||||
But don't worry, you can just update any of that in the `.env` files afterwards.
|
|
||||||
|
|
||||||
The input variables, with their default values (some auto generated) are:
|
|
||||||
|
|
||||||
- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env).
|
|
||||||
- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels and project name (no spaces, no periods) (in .env).
|
|
||||||
- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above.
|
|
||||||
- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env).
|
|
||||||
- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env).
|
|
||||||
- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env.
|
|
||||||
- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env.
|
|
||||||
- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later in .env.
|
|
||||||
- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env.
|
|
||||||
- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above.
|
|
||||||
- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env.
|
|
||||||
|
|
||||||
## Backend Development
|
## Backend Development
|
||||||
|
|
||||||
Backend docs: [backend/README.md](./backend/README.md).
|
Backend docs: [backend/README.md](./backend/README.md).
|
||||||
@@ -222,7 +163,7 @@ Deployment docs: [deployment.md](./deployment.md).
|
|||||||
|
|
||||||
General development docs: [development.md](./development.md).
|
General development docs: [development.md](./development.md).
|
||||||
|
|
||||||
This includes using Docker Compose, custom local domains, `.env` configurations, etc.
|
This includes the local FastAPI and Vite workflow, Docker Compose services, custom local domains, `.env` configuration, and more.
|
||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
|||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
Security is very important for this project and its community. 🔒
|
|
||||||
|
|
||||||
Learn more about it below. 👇
|
|
||||||
|
|
||||||
## Versions
|
|
||||||
|
|
||||||
The latest version or release is supported.
|
|
||||||
|
|
||||||
You are encouraged to write tests for your application and update your versions frequently after ensuring that your tests are passing. This way you will benefit from the latest features, bug fixes, and **security fixes**.
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
If you think you found a vulnerability, and even if you are not sure about it, please report it right away by sending an email to: security@tiangolo.com. Please try to be as explicit as possible, describing all the steps and example code to reproduce the security issue.
|
|
||||||
|
|
||||||
I (the author, [@tiangolo](https://twitter.com/tiangolo)) will review it thoroughly and get back to you.
|
|
||||||
|
|
||||||
## Public Discussions
|
|
||||||
|
|
||||||
Please restrain from publicly discussing a potential security vulnerability. 🙊
|
|
||||||
|
|
||||||
It's better to discuss privately and try to find a solution first, to limit the potential impact as much as possible.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Thanks for your help!
|
|
||||||
|
|
||||||
The community and I thank you for that. 🙇
|
|
||||||
+37
-16
@@ -1,16 +1,28 @@
|
|||||||
FROM python:3.10
|
FROM oven/bun:1 AS frontend-build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json bun.lock /app/
|
||||||
|
COPY frontend/package.json /app/frontend/
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
RUN bun install
|
||||||
|
|
||||||
|
COPY ./frontend /app/frontend
|
||||||
|
|
||||||
|
ARG VITE_API_URL=
|
||||||
|
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.14
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /app/
|
|
||||||
|
|
||||||
# Install uv
|
# Install uv
|
||||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv
|
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/
|
||||||
|
|
||||||
# Place executables in the environment at the front of the path
|
|
||||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#using-the-environment
|
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
|
||||||
|
|
||||||
# Compile bytecode
|
# Compile bytecode
|
||||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#compiling-bytecode
|
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#compiling-bytecode
|
||||||
@@ -20,25 +32,34 @@ ENV UV_COMPILE_BYTECODE=1
|
|||||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#caching
|
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#caching
|
||||||
ENV UV_LINK_MODE=copy
|
ENV UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
WORKDIR /app/
|
||||||
|
|
||||||
|
# Place executables in the environment at the front of the path
|
||||||
|
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#using-the-environment
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
|
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
uv sync --frozen --no-install-project
|
uv sync --frozen --no-install-workspace --package app
|
||||||
|
|
||||||
ENV PYTHONPATH=/app
|
COPY ./backend/scripts /app/backend/scripts
|
||||||
|
|
||||||
COPY ./scripts /app/scripts
|
COPY ./backend/pyproject.toml ./backend/alembic.ini /app/backend/
|
||||||
|
|
||||||
COPY ./pyproject.toml ./uv.lock ./alembic.ini /app/
|
COPY ./backend/app /app/backend/app
|
||||||
|
|
||||||
COPY ./app /app/app
|
COPY --from=frontend-build /app/backend/app/frontend /app/backend/app/frontend
|
||||||
COPY ./tests /app/tests
|
|
||||||
|
|
||||||
# Sync the project
|
# Sync the project
|
||||||
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
|
# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
uv sync --frozen --package app
|
||||||
|
|
||||||
CMD ["fastapi", "run", "--workers", "4", "app/main.py"]
|
WORKDIR /app/backend/
|
||||||
|
|
||||||
|
CMD ["fastapi", "run", "--workers", "4"]
|
||||||
|
|||||||
+40
-67
@@ -5,27 +5,29 @@
|
|||||||
* [Docker](https://www.docker.com/).
|
* [Docker](https://www.docker.com/).
|
||||||
* [uv](https://docs.astral.sh/uv/) for Python package and environment management.
|
* [uv](https://docs.astral.sh/uv/) for Python package and environment management.
|
||||||
|
|
||||||
## Docker Compose
|
## Local Development
|
||||||
|
|
||||||
Start the local development environment with Docker Compose following the guide in [../development.md](../development.md).
|
Run the backend locally and connect it to PostgreSQL in Docker Compose.
|
||||||
|
|
||||||
## General Workflow
|
From the project root, start PostgreSQL and Mailcatcher:
|
||||||
|
|
||||||
By default, the dependencies are managed with [uv](https://docs.astral.sh/uv/), go there and install it.
|
```console
|
||||||
|
$ docker compose up -d db mailcatcher
|
||||||
|
```
|
||||||
|
|
||||||
From `./backend/` you can install all the dependencies with:
|
Then, from `./backend/`, install the dependencies, prepare the database, and start the development server:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ uv sync
|
$ uv sync
|
||||||
|
$ uv run bash scripts/prestart.sh
|
||||||
|
$ uv run fastapi dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Then you can activate the virtual environment with:
|
The API is available at `http://localhost:8000`, with automatic interactive docs at `http://localhost:8000/docs`.
|
||||||
|
|
||||||
```console
|
## General Workflow
|
||||||
$ source .venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
Make sure your editor is using the correct Python virtual environment, with the interpreter at `backend/.venv/bin/python`.
|
Run backend commands from `./backend/` with `uv run`. Make sure your editor uses the Python interpreter at `.venv/bin/python` in the project root.
|
||||||
|
|
||||||
Modify or add SQLModel models for data and SQL tables in `./backend/app/models.py`, API endpoints in `./backend/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/crud.py`.
|
Modify or add SQLModel models for data and SQL tables in `./backend/app/models.py`, API endpoints in `./backend/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/crud.py`.
|
||||||
|
|
||||||
@@ -35,66 +37,33 @@ There are already configurations in place to run the backend through the VS Code
|
|||||||
|
|
||||||
The setup is also already configured so you can run the tests through the VS Code Python tests tab.
|
The setup is also already configured so you can run the tests through the VS Code Python tests tab.
|
||||||
|
|
||||||
## Docker Compose Override
|
## Full Stack with Docker Compose
|
||||||
|
|
||||||
During development, you can change Docker Compose settings that will only affect the local development environment in the file `docker-compose.override.yml`.
|
To run the backend and built frontend in Docker Compose:
|
||||||
|
|
||||||
The changes to that file only affect the local development environment, not the production environment. So, you can add "temporary" changes that help the development workflow.
|
|
||||||
|
|
||||||
For example, the directory with the backend code is synchronized in the Docker container, copying the code you change live to the directory inside the container. That allows you to test your changes right away, without having to build the Docker image again. It should only be done during development, for production, you should build the Docker image with a recent version of the backend code. But during development, it allows you to iterate very fast.
|
|
||||||
|
|
||||||
There is also a command override that runs `fastapi run --reload` instead of the default `fastapi run`. It starts a single server process (instead of multiple, as would be for production) and reloads the process whenever the code changes. Have in mind that if you have a syntax error and save the Python file, it will break and exit, and the container will stop. After that, you can restart the container by fixing the error and running again:
|
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
$ docker compose run --rm backend bash scripts/prestart.sh
|
||||||
$ docker compose watch
|
$ docker compose watch
|
||||||
```
|
```
|
||||||
|
|
||||||
There is also a commented out `command` override, you can uncomment it and comment the default one. It makes the backend container run a process that does "nothing", but keeps the container alive. That allows you to get inside your running container and execute commands inside, for example a Python interpreter to test installed dependencies, or start the development server that reloads when it detects changes.
|
The application is available at `http://localhost:8000`.
|
||||||
|
|
||||||
To get inside the container with a `bash` session you can start the stack with:
|
### Docker Compose Override
|
||||||
|
|
||||||
```console
|
The `compose.override.yml` file contains local settings for published ports, source synchronization, automatic image rebuilds, and backend reloads. Docker Compose applies it automatically when you run `docker compose` without an explicit file list.
|
||||||
$ docker compose watch
|
|
||||||
```
|
|
||||||
|
|
||||||
and then in another terminal, `exec` inside the running container:
|
To open a shell in the backend container:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ docker compose exec backend bash
|
$ docker compose exec backend bash
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see an output like:
|
|
||||||
|
|
||||||
```console
|
|
||||||
root@7f2607af31c3:/app#
|
|
||||||
```
|
|
||||||
|
|
||||||
that means that you are in a `bash` session inside your container, as a `root` user, under the `/app` directory, this directory has another directory called "app" inside, that's where your code lives inside the container: `/app/app`.
|
|
||||||
|
|
||||||
There you can use the `fastapi run --reload` command to run the debug live reloading server.
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ fastapi run --reload app/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
...it will look like:
|
|
||||||
|
|
||||||
```console
|
|
||||||
root@7f2607af31c3:/app# fastapi run --reload app/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
and then hit enter. That runs the live reloading server that auto reloads when it detects code changes.
|
|
||||||
|
|
||||||
Nevertheless, if it doesn't detect a change but a syntax error, it will just stop with an error. But as the container is still alive and you are in a Bash session, you can quickly restart it after fixing the error, running the same command ("up arrow" and "Enter").
|
|
||||||
|
|
||||||
...this previous detail is what makes it useful to have the container alive doing nothing and then, in a Bash session, make it run the live reload server.
|
|
||||||
|
|
||||||
## Backend tests
|
## Backend tests
|
||||||
|
|
||||||
To test the backend run:
|
To test the backend from the `backend` directory, run:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ bash ./scripts/test.sh
|
$ uv run bash ./scripts/test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The tests run with Pytest, modify and add tests to `./backend/tests/`.
|
The tests run with Pytest, modify and add tests to `./backend/tests/`.
|
||||||
@@ -123,22 +92,14 @@ When the tests are run, a file `htmlcov/index.html` is generated, you can open i
|
|||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
As during local development your app directory is mounted as a volume inside the container, you can also run the migrations with `alembic` commands inside the container and the migration code will be in your app directory (instead of being only inside the container). So you can add it to your git repository.
|
Make sure you create a revision of your models and upgrade the database with that revision every time you change them. From the `backend` directory, use `uv` to run Alembic against the PostgreSQL container:
|
||||||
|
|
||||||
Make sure you create a "revision" of your models and that you "upgrade" your database with that revision every time you change them. As this is what will update the tables in your database. Otherwise, your application will have errors.
|
|
||||||
|
|
||||||
* Start an interactive session in the backend container:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ docker compose exec backend bash
|
|
||||||
```
|
|
||||||
|
|
||||||
* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`.
|
* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`.
|
||||||
|
|
||||||
* After changing a model (for example, adding a column), inside the container, create a revision, e.g.:
|
* After changing a model (for example, adding a column), create a revision:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ alembic revision --autogenerate -m "Add column last_name to User model"
|
$ uv run alembic revision --autogenerate -m "Add column last_name to User model"
|
||||||
```
|
```
|
||||||
|
|
||||||
* Commit to the git repository the files generated in the alembic directory.
|
* Commit to the git repository the files generated in the alembic directory.
|
||||||
@@ -146,7 +107,7 @@ $ alembic revision --autogenerate -m "Add column last_name to User model"
|
|||||||
* After creating the revision, run the migration in the database (this is what will actually change the database):
|
* After creating the revision, run the migration in the database (this is what will actually change the database):
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ alembic upgrade head
|
$ uv run alembic upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in:
|
If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in:
|
||||||
@@ -165,8 +126,20 @@ If you don't want to start with the default models and want to remove them / mod
|
|||||||
|
|
||||||
## Email Templates
|
## Email Templates
|
||||||
|
|
||||||
The email templates are in `./backend/app/email-templates/`. Here, there are two directories: `build` and `src`. The `src` directory contains the source files that are used to build the final email templates. The `build` directory contains the final email templates that are used by the application.
|
The email templates are written with [React Email](https://react.email) in `./packages/react-email/`. The `emails` directory holds one component per email and the `ui` directory holds the shared components (layout, heading, button, link, callout).
|
||||||
|
|
||||||
Before continuing, ensure you have the [MJML extension](https://marketplace.visualstudio.com/items?itemName=attilabuti.vscode-mjml) installed in your VS Code.
|
The rendered HTML in `./backend/app/email-templates/` is generated from those components, it is what the application sends, and it shouldn't be edited by hand.
|
||||||
|
|
||||||
Once you have the MJML extension installed, you can create a new email template in the `src` directory. After creating the new email template and with the `.mjml` file open in your editor, open the command palette with `Ctrl+Shift+P` and search for `MJML: Export to HTML`. This will convert the `.mjml` file to a `.html` file and now you can save it in the build directory.
|
To preview the emails while editing them, start the dev server from the root of the project:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ bun run email:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Values coming from the backend are declared as Jinja placeholders in the component props, for example `username = "{{ username }}"`. The context for each email is built in `generate_*_email()` in `./backend/app/utils.py`, so a new placeholder needs to be added there too.
|
||||||
|
|
||||||
|
Once you are done, regenerate the templates used by the application:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ bun run email:export
|
||||||
|
```
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ config = context.config
|
|||||||
|
|
||||||
# Interpret the config file for Python logging.
|
# Interpret the config file for Python logging.
|
||||||
# This line sets up loggers basically.
|
# This line sets up loggers basically.
|
||||||
|
assert config.config_file_name is not None
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
# add your model's MetaData object here
|
# add your model's MetaData object here
|
||||||
@@ -62,6 +63,7 @@ def run_migrations_online():
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
configuration = config.get_section(config.config_ini_section)
|
configuration = config.get_section(config.config_ini_section)
|
||||||
|
assert configuration is not None
|
||||||
configuration["sqlalchemy.url"] = get_url()
|
configuration["sqlalchemy.url"] = get_url()
|
||||||
connectable = engine_from_config(
|
connectable = engine_from_config(
|
||||||
configuration,
|
configuration,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def upgrade():
|
|||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
op.drop_constraint(None, 'item', type_='foreignkey')
|
op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey')
|
||||||
op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id'])
|
op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id'])
|
||||||
op.alter_column('item', 'owner_id',
|
op.alter_column('item', 'owner_id',
|
||||||
existing_type=sa.UUID(),
|
existing_type=sa.UUID(),
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Add created_at to User and Item
|
||||||
|
|
||||||
|
Revision ID: fe56fa70289e
|
||||||
|
Revises: 1a31ce608336
|
||||||
|
Create Date: 2026-01-23 15:50:37.171462
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel.sql.sqltypes
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'fe56fa70289e'
|
||||||
|
down_revision = '1a31ce608336'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('item', sa.Column('created_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column('user', sa.Column('created_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('user', 'created_at')
|
||||||
|
op.drop_column('item', 'created_at')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -18,7 +18,7 @@ reusable_oauth2 = OAuth2PasswordBearer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Generator[Session, None, None]:
|
def get_db() -> Generator[Session]:
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
|||||||
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
||||||
)
|
)
|
||||||
token_data = TokenPayload(**payload)
|
token_data = TokenPayload(**payload)
|
||||||
except (InvalidTokenError, ValidationError):
|
except InvalidTokenError, ValidationError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Could not validate credentials",
|
detail="Could not validate credentials",
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ api_router.include_router(utils.router)
|
|||||||
api_router.include_router(items.router)
|
api_router.include_router(items.router)
|
||||||
|
|
||||||
|
|
||||||
if settings.ENVIRONMENT == "local":
|
if settings.FASTAPI_ENV == "development":
|
||||||
api_router.include_router(private.router)
|
api_router.include_router(private.router)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from sqlmodel import func, select
|
from sqlmodel import col, func, select
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
|
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
|
||||||
@@ -21,7 +21,9 @@ def read_items(
|
|||||||
if current_user.is_superuser:
|
if current_user.is_superuser:
|
||||||
count_statement = select(func.count()).select_from(Item)
|
count_statement = select(func.count()).select_from(Item)
|
||||||
count = session.exec(count_statement).one()
|
count = session.exec(count_statement).one()
|
||||||
statement = select(Item).offset(skip).limit(limit)
|
statement = (
|
||||||
|
select(Item).order_by(col(Item.created_at).desc()).offset(skip).limit(limit)
|
||||||
|
)
|
||||||
items = session.exec(statement).all()
|
items = session.exec(statement).all()
|
||||||
else:
|
else:
|
||||||
count_statement = (
|
count_statement = (
|
||||||
@@ -33,12 +35,14 @@ def read_items(
|
|||||||
statement = (
|
statement = (
|
||||||
select(Item)
|
select(Item)
|
||||||
.where(Item.owner_id == current_user.id)
|
.where(Item.owner_id == current_user.id)
|
||||||
|
.order_by(col(Item.created_at).desc())
|
||||||
.offset(skip)
|
.offset(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
items = session.exec(statement).all()
|
items = session.exec(statement).all()
|
||||||
|
|
||||||
return ItemsPublic(data=items, count=count)
|
items_public = [ItemPublic.model_validate(item) for item in items]
|
||||||
|
return ItemsPublic(data=items_public, count=count)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}", response_model=ItemPublic)
|
@router.get("/{id}", response_model=ItemPublic)
|
||||||
@@ -50,7 +54,7 @@ def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) ->
|
|||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
||||||
raise HTTPException(status_code=400, detail="Not enough permissions")
|
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@@ -83,7 +87,7 @@ def update_item(
|
|||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
||||||
raise HTTPException(status_code=400, detail="Not enough permissions")
|
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||||
update_dict = item_in.model_dump(exclude_unset=True)
|
update_dict = item_in.model_dump(exclude_unset=True)
|
||||||
item.sqlmodel_update(update_dict)
|
item.sqlmodel_update(update_dict)
|
||||||
session.add(item)
|
session.add(item)
|
||||||
@@ -103,7 +107,7 @@ def delete_item(
|
|||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Item not found")
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
if not current_user.is_superuser and (item.owner_id != current_user.id):
|
||||||
raise HTTPException(status_code=400, detail="Not enough permissions")
|
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||||
session.delete(item)
|
session.delete(item)
|
||||||
session.commit()
|
session.commit()
|
||||||
return Message(message="Item deleted successfully")
|
return Message(message="Item deleted successfully")
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ from app import crud
|
|||||||
from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
|
from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import get_password_hash
|
from app.models import Message, NewPassword, Token, UserPublic, UserUpdate
|
||||||
from app.models import Message, NewPassword, Token, UserPublic
|
|
||||||
from app.utils import (
|
from app.utils import (
|
||||||
generate_password_reset_token,
|
generate_password_reset_token,
|
||||||
generate_reset_password_email,
|
generate_reset_password_email,
|
||||||
@@ -58,21 +57,21 @@ def recover_password(email: str, session: SessionDep) -> Message:
|
|||||||
"""
|
"""
|
||||||
user = crud.get_user_by_email(session=session, email=email)
|
user = crud.get_user_by_email(session=session, email=email)
|
||||||
|
|
||||||
if not user:
|
# Always return the same response to prevent email enumeration attacks
|
||||||
raise HTTPException(
|
# Only send email if user actually exists
|
||||||
status_code=404,
|
if user:
|
||||||
detail="The user with this email does not exist in the system.",
|
password_reset_token = generate_password_reset_token(email=email)
|
||||||
|
email_data = generate_reset_password_email(
|
||||||
|
email_to=user.email, email=email, token=password_reset_token
|
||||||
)
|
)
|
||||||
password_reset_token = generate_password_reset_token(email=email)
|
send_email(
|
||||||
email_data = generate_reset_password_email(
|
email_to=user.email,
|
||||||
email_to=user.email, email=email, token=password_reset_token
|
subject=email_data.subject,
|
||||||
|
html_content=email_data.html_content,
|
||||||
|
)
|
||||||
|
return Message(
|
||||||
|
message="If that email is registered, we sent a password recovery link"
|
||||||
)
|
)
|
||||||
send_email(
|
|
||||||
email_to=user.email,
|
|
||||||
subject=email_data.subject,
|
|
||||||
html_content=email_data.html_content,
|
|
||||||
)
|
|
||||||
return Message(message="Password recovery email sent")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reset-password/")
|
@router.post("/reset-password/")
|
||||||
@@ -85,16 +84,16 @@ def reset_password(session: SessionDep, body: NewPassword) -> Message:
|
|||||||
raise HTTPException(status_code=400, detail="Invalid token")
|
raise HTTPException(status_code=400, detail="Invalid token")
|
||||||
user = crud.get_user_by_email(session=session, email=email)
|
user = crud.get_user_by_email(session=session, email=email)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
# Don't reveal that the user doesn't exist - use same error as invalid token
|
||||||
status_code=404,
|
raise HTTPException(status_code=400, detail="Invalid token")
|
||||||
detail="The user with this email does not exist in the system.",
|
|
||||||
)
|
|
||||||
elif not user.is_active:
|
elif not user.is_active:
|
||||||
raise HTTPException(status_code=400, detail="Inactive user")
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
hashed_password = get_password_hash(password=body.new_password)
|
user_in_update = UserUpdate(password=body.new_password)
|
||||||
user.hashed_password = hashed_password
|
crud.update_user(
|
||||||
session.add(user)
|
session=session,
|
||||||
session.commit()
|
db_user=user,
|
||||||
|
user_in=user_in_update,
|
||||||
|
)
|
||||||
return Message(message="Password updated successfully")
|
return Message(message="Password updated successfully")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,13 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
|
|||||||
count_statement = select(func.count()).select_from(User)
|
count_statement = select(func.count()).select_from(User)
|
||||||
count = session.exec(count_statement).one()
|
count = session.exec(count_statement).one()
|
||||||
|
|
||||||
statement = select(User).offset(skip).limit(limit)
|
statement = (
|
||||||
|
select(User).order_by(col(User.created_at).desc()).offset(skip).limit(limit)
|
||||||
|
)
|
||||||
users = session.exec(statement).all()
|
users = session.exec(statement).all()
|
||||||
|
|
||||||
return UsersPublic(data=users, count=count)
|
users_public = [UserPublic.model_validate(user) for user in users]
|
||||||
|
return UsersPublic(data=users_public, count=count)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -104,7 +107,8 @@ def update_password_me(
|
|||||||
"""
|
"""
|
||||||
Update own password.
|
Update own password.
|
||||||
"""
|
"""
|
||||||
if not verify_password(body.current_password, current_user.hashed_password):
|
verified, _ = verify_password(body.current_password, current_user.hashed_password)
|
||||||
|
if not verified:
|
||||||
raise HTTPException(status_code=400, detail="Incorrect password")
|
raise HTTPException(status_code=400, detail="Incorrect password")
|
||||||
if body.current_password == body.new_password:
|
if body.current_password == body.new_password:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -170,6 +174,8 @@ def read_user_by_id(
|
|||||||
status_code=403,
|
status_code=403,
|
||||||
detail="The user doesn't have enough privileges",
|
detail="The user doesn't have enough privileges",
|
||||||
)
|
)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -220,7 +226,7 @@ def delete_user(
|
|||||||
status_code=403, detail="Super users are not allowed to delete themselves"
|
status_code=403, detail="Super users are not allowed to delete themselves"
|
||||||
)
|
)
|
||||||
statement = delete(Item).where(col(Item.owner_id) == user_id)
|
statement = delete(Item).where(col(Item.owner_id) == user_id)
|
||||||
session.exec(statement) # type: ignore
|
session.exec(statement)
|
||||||
session.delete(user)
|
session.delete(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
return Message(message="User deleted successfully")
|
return Message(message="User deleted successfully")
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import secrets
|
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Literal, Self
|
||||||
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
AnyUrl,
|
|
||||||
BeforeValidator,
|
|
||||||
EmailStr,
|
EmailStr,
|
||||||
HttpUrl,
|
HttpUrl,
|
||||||
PostgresDsn,
|
PostgresDsn,
|
||||||
@@ -12,15 +9,6 @@ from pydantic import (
|
|||||||
model_validator,
|
model_validator,
|
||||||
)
|
)
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from typing_extensions import Self
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cors(v: Any) -> list[str] | str:
|
|
||||||
if isinstance(v, str) and not v.startswith("["):
|
|
||||||
return [i.strip() for i in v.split(",") if i.strip()]
|
|
||||||
elif isinstance(v, list | str):
|
|
||||||
return v
|
|
||||||
raise ValueError(v)
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -31,22 +19,11 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
API_V1_STR: str = "/api/v1"
|
API_V1_STR: str = "/api/v1"
|
||||||
SECRET_KEY: str = secrets.token_urlsafe(32)
|
SECRET_KEY: str
|
||||||
# 60 minutes * 24 hours * 8 days = 8 days
|
# 60 minutes * 24 hours * 8 days = 8 days
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
||||||
FRONTEND_HOST: str = "http://localhost:5173"
|
FRONTEND_HOST: str = "http://localhost:5173"
|
||||||
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
|
FASTAPI_ENV: Literal["development"] | None = None
|
||||||
|
|
||||||
BACKEND_CORS_ORIGINS: Annotated[
|
|
||||||
list[AnyUrl] | str, BeforeValidator(parse_cors)
|
|
||||||
] = []
|
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
|
||||||
@property
|
|
||||||
def all_cors_origins(self) -> list[str]:
|
|
||||||
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
|
|
||||||
self.FRONTEND_HOST
|
|
||||||
]
|
|
||||||
|
|
||||||
PROJECT_NAME: str
|
PROJECT_NAME: str
|
||||||
SENTRY_DSN: HttpUrl | None = None
|
SENTRY_DSN: HttpUrl | None = None
|
||||||
@@ -100,7 +77,7 @@ class Settings(BaseSettings):
|
|||||||
f'The value of {var_name} is "changethis", '
|
f'The value of {var_name} is "changethis", '
|
||||||
"for security, please change it, at least for deployments."
|
"for security, please change it, at least for deployments."
|
||||||
)
|
)
|
||||||
if self.ENVIRONMENT == "local":
|
if self.FASTAPI_ENV == "development":
|
||||||
warnings.warn(message, stacklevel=1)
|
warnings.warn(message, stacklevel=1)
|
||||||
else:
|
else:
|
||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from passlib.context import CryptContext
|
from pwdlib import PasswordHash
|
||||||
|
from pwdlib.hashers.argon2 import Argon2Hasher
|
||||||
|
from pwdlib.hashers.bcrypt import BcryptHasher
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
password_hash = PasswordHash(
|
||||||
|
(
|
||||||
|
Argon2Hasher(),
|
||||||
|
BcryptHasher(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
|
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
|
||||||
expire = datetime.now(timezone.utc) + expires_delta
|
expire = datetime.now(UTC) + expires_delta
|
||||||
to_encode = {"exp": expire, "sub": str(subject)}
|
to_encode = {"exp": expire, "sub": str(subject)}
|
||||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
plain_password: str, hashed_password: str
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
return password_hash.verify_and_update(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
def get_password_hash(password: str) -> str:
|
||||||
return pwd_context.hash(password)
|
return password_hash.hash(password)
|
||||||
|
|||||||
+15
-1
@@ -37,12 +37,26 @@ def get_user_by_email(*, session: Session, email: str) -> User | None:
|
|||||||
return session_user
|
return session_user
|
||||||
|
|
||||||
|
|
||||||
|
# Dummy hash to use for timing attack prevention when user is not found
|
||||||
|
# This is an Argon2 hash of a random password, used to ensure constant-time comparison
|
||||||
|
DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$MjQyZWE1MzBjYjJlZTI0Yw$YTU4NGM5ZTZmYjE2NzZlZjY0ZWY3ZGRkY2U2OWFjNjk"
|
||||||
|
|
||||||
|
|
||||||
def authenticate(*, session: Session, email: str, password: str) -> User | None:
|
def authenticate(*, session: Session, email: str, password: str) -> User | None:
|
||||||
db_user = get_user_by_email(session=session, email=email)
|
db_user = get_user_by_email(session=session, email=email)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
|
# Prevent timing attacks by running password verification even when user doesn't exist
|
||||||
|
# This ensures the response time is similar whether or not the email exists
|
||||||
|
verify_password(password, DUMMY_HASH)
|
||||||
return None
|
return None
|
||||||
if not verify_password(password, db_user.hashed_password):
|
verified, updated_password_hash = verify_password(password, db_user.hashed_password)
|
||||||
|
if not verified:
|
||||||
return None
|
return None
|
||||||
|
if updated_password_hash:
|
||||||
|
db_user.hashed_password = updated_password_hash
|
||||||
|
session.add(db_user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_user)
|
||||||
return db_user
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
|
|
||||||
.ReadMsgBody { width:100%; }
|
|
||||||
.ExternalClass { width:100%; }
|
|
||||||
.ExternalClass * { line-height:100%; }
|
|
||||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
|
||||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
|
||||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
|
||||||
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
|
|
||||||
@-ms-viewport { width:320px; }
|
|
||||||
@viewport { width:320px; }
|
|
||||||
}</style><!--<![endif]--><!--[if mso]>
|
|
||||||
<xml>
|
|
||||||
<o:OfficeDocumentSettings>
|
|
||||||
<o:AllowPNG/>
|
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
||||||
</o:OfficeDocumentSettings>
|
|
||||||
</xml>
|
|
||||||
<![endif]--><!--[if lte mso 11]>
|
|
||||||
<style type="text/css">
|
|
||||||
.outlook-group-fix { width:100% !important; }
|
|
||||||
</style>
|
|
||||||
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
|
|
||||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
|
||||||
}</style><style type="text/css"></style></head><body style="background-color:#fafbfc;"><div style="background-color:#fafbfc;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="background:#ffffff;background-color:#ffffff;Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:40px 20px;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:560px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%"><tr><td align="center" style="font-size:0px;padding:35px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;line-height:1;text-align:center;color:#333333;">{{ project_name }} - New Account</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><span>Welcome to your new account!</span></div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Here are your account details:</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Username: {{ username }}</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Password: {{ password }}</div></td></tr><tr><td align="center" vertical-align="middle" style="font-size:0px;padding:15px 30px;word-break:break-word;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"><tr><td align="center" bgcolor="#009688" role="presentation" style="border:none;border-radius:8px;cursor:auto;padding:10px 25px;background:#009688;" valign="middle"><a href="{{ link }}" style="background:#009688;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:18px;font-weight:normal;line-height:120%;Margin:0;text-decoration:none;text-transform:none;" target="_blank">Go to Dashboard</a></td></tr></table></td></tr><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:510px;" role="presentation" width="510px" ><tr><td style="height:0;line-height:0;">
|
|
||||||
</td></tr></table><![endif]--></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
|
|
||||||
.ReadMsgBody { width:100%; }
|
|
||||||
.ExternalClass { width:100%; }
|
|
||||||
.ExternalClass * { line-height:100%; }
|
|
||||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
|
||||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
|
||||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
|
||||||
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
|
|
||||||
@-ms-viewport { width:320px; }
|
|
||||||
@viewport { width:320px; }
|
|
||||||
}</style><!--<![endif]--><!--[if mso]>
|
|
||||||
<xml>
|
|
||||||
<o:OfficeDocumentSettings>
|
|
||||||
<o:AllowPNG/>
|
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
||||||
</o:OfficeDocumentSettings>
|
|
||||||
</xml>
|
|
||||||
<![endif]--><!--[if lte mso 11]>
|
|
||||||
<style type="text/css">
|
|
||||||
.outlook-group-fix { width:100% !important; }
|
|
||||||
</style>
|
|
||||||
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
|
|
||||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
|
||||||
}</style><style type="text/css"></style></head><body style="background-color:#fafbfc;"><div style="background-color:#fafbfc;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="background:#ffffff;background-color:#ffffff;Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:40px 20px;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:560px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%"><tr><td align="center" style="font-size:0px;padding:35px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:20px;line-height:1;text-align:center;color:#333333;">{{ project_name }} - Password Recovery</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><span>Hello {{ username }}</span></div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">We've received a request to reset your password. You can do it by clicking the button below:</div></td></tr><tr><td align="center" vertical-align="middle" style="font-size:0px;padding:15px 30px;word-break:break-word;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"><tr><td align="center" bgcolor="#009688" role="presentation" style="border:none;border-radius:8px;cursor:auto;padding:10px 25px;background:#009688;" valign="middle"><a href="{{ link }}" style="background:#009688;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:18px;font-weight:normal;line-height:120%;Margin:0;text-decoration:none;text-transform:none;" target="_blank">Reset password</a></td></tr></table></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Or copy and paste the following link into your browser:</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><a href="{{ link }}">{{ link }}</a></div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">This password will expire in {{ valid_hours }} hours.</div></td></tr><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:510px;" role="presentation" width="510px" ><tr><td style="height:0;line-height:0;">
|
|
||||||
</td></tr></table><![endif]--></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:14px;line-height:1;text-align:center;color:#555555;">If you didn't request a password recovery you can disregard this email.</div></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
|
|
||||||
.ReadMsgBody { width:100%; }
|
|
||||||
.ExternalClass { width:100%; }
|
|
||||||
.ExternalClass * { line-height:100%; }
|
|
||||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
|
||||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
|
||||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
|
||||||
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
|
|
||||||
@-ms-viewport { width:320px; }
|
|
||||||
@viewport { width:320px; }
|
|
||||||
}</style><!--<![endif]--><!--[if mso]>
|
|
||||||
<xml>
|
|
||||||
<o:OfficeDocumentSettings>
|
|
||||||
<o:AllowPNG/>
|
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
||||||
</o:OfficeDocumentSettings>
|
|
||||||
</xml>
|
|
||||||
<![endif]--><!--[if lte mso 11]>
|
|
||||||
<style type="text/css">
|
|
||||||
.outlook-group-fix { width:100% !important; }
|
|
||||||
</style>
|
|
||||||
<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
|
|
||||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
|
||||||
}</style><style type="text/css"></style></head><body style="background-color:#fafbfc;"><div style="background-color:#fafbfc;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="background:#ffffff;background-color:#ffffff;Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:40px 20px;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:560px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%"><tr><td align="center" style="font-size:0px;padding:35px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:20px;line-height:1;text-align:center;color:#333333;">{{ project_name }}</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><span>Test email for: {{ email }}</span></div></td></tr><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:510px;" role="presentation" width="510px" ><tr><td style="height:0;line-height:0;">
|
|
||||||
</td></tr></table><![endif]--></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,15 +0,0 @@
|
|||||||
<mjml>
|
|
||||||
<mj-body background-color="#fafbfc">
|
|
||||||
<mj-section background-color="#fff" padding="40px 20px">
|
|
||||||
<mj-column vertical-align="middle" width="100%">
|
|
||||||
<mj-text align="center" padding="35px" font-size="20px" color="#333">{{ project_name }} - New Account</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555"><span>Welcome to your new account!</span></mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Here are your account details:</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Username: {{ username }}</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Password: {{ password }}</mj-text>
|
|
||||||
<mj-button align="center" font-size="18px" background-color="#009688" border-radius="8px" color="#fff" href="{{ link }}" padding="15px 30px">Go to Dashboard</mj-button>
|
|
||||||
<mj-divider border-color="#ccc" border-width="2px"></mj-divider>
|
|
||||||
</mj-column>
|
|
||||||
</mj-section>
|
|
||||||
</mj-body>
|
|
||||||
</mjml>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<mjml>
|
|
||||||
<mj-body background-color="#fafbfc">
|
|
||||||
<mj-section background-color="#fff" padding="40px 20px">
|
|
||||||
<mj-column vertical-align="middle" width="100%">
|
|
||||||
<mj-text align="center" padding="35px" font-size="20px" font-family="Arial, Helvetica, sans-serif" color="#333">{{ project_name }} - Password Recovery</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555"><span>Hello {{ username }}</span></mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">We've received a request to reset your password. You can do it by clicking the button below:</mj-text>
|
|
||||||
<mj-button align="center" font-size="18px" background-color="#009688" border-radius="8px" color="#fff" href="{{ link }}" padding="15px 30px">Reset password</mj-button>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Or copy and paste the following link into your browser:</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555"><a href="{{ link }}">{{ link }}</a></mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">This password will expire in {{ valid_hours }} hours.</mj-text>
|
|
||||||
<mj-divider border-color="#ccc" border-width="2px"></mj-divider>
|
|
||||||
<mj-text align="center" font-size="14px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">If you didn't request a password recovery you can disregard this email.</mj-text>
|
|
||||||
</mj-column>
|
|
||||||
</mj-section>
|
|
||||||
</mj-body>
|
|
||||||
</mjml>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<mjml>
|
|
||||||
<mj-body background-color="#fafbfc">
|
|
||||||
<mj-section background-color="#fff" padding="40px 20px">
|
|
||||||
<mj-column vertical-align="middle" width="100%">
|
|
||||||
<mj-text align="center" padding="35px" font-size="20px" font-family="Arial, Helvetica, sans-serif" color="#333">{{ project_name }}</mj-text>
|
|
||||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family=", sans-serif" color="#555"><span>Test email for: {{ email }}</span></mj-text>
|
|
||||||
<mj-divider border-color="#ccc" border-width="2px"></mj-divider>
|
|
||||||
</mj-column>
|
|
||||||
</mj-section>
|
|
||||||
</mj-body>
|
|
||||||
</mjml>
|
|
||||||
File diff suppressed because one or more lines are too long
+13
-10
@@ -1,3 +1,5 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
@@ -6,12 +8,14 @@ from starlette.middleware.cors import CORSMiddleware
|
|||||||
from app.api.main import api_router
|
from app.api.main import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
FRONTEND_DIR = Path(__file__).parent / "frontend"
|
||||||
|
|
||||||
|
|
||||||
def custom_generate_unique_id(route: APIRoute) -> str:
|
def custom_generate_unique_id(route: APIRoute) -> str:
|
||||||
return f"{route.tags[0]}-{route.name}"
|
return f"{route.tags[0]}-{route.name}"
|
||||||
|
|
||||||
|
|
||||||
if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
|
if settings.SENTRY_DSN and settings.FASTAPI_ENV != "development":
|
||||||
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
|
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -20,14 +24,13 @@ app = FastAPI(
|
|||||||
generate_unique_id_function=custom_generate_unique_id,
|
generate_unique_id_function=custom_generate_unique_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set all CORS enabled origins
|
app.add_middleware(
|
||||||
if settings.all_cors_origins:
|
CORSMiddleware,
|
||||||
app.add_middleware(
|
allow_origins=[settings.FRONTEND_HOST],
|
||||||
CORSMiddleware,
|
allow_credentials=True,
|
||||||
allow_origins=settings.all_cors_origins,
|
allow_methods=["*"],
|
||||||
allow_credentials=True,
|
allow_headers=["*"],
|
||||||
allow_methods=["*"],
|
)
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||||
|
app.frontend("/", directory=FRONTEND_DIR)
|
||||||
|
|||||||
+25
-5
@@ -1,9 +1,15 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from pydantic import EmailStr
|
from pydantic import EmailStr
|
||||||
|
from sqlalchemy import DateTime
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
def get_datetime_utc() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
# Shared properties
|
# Shared properties
|
||||||
class UserBase(SQLModel):
|
class UserBase(SQLModel):
|
||||||
email: EmailStr = Field(unique=True, index=True, max_length=255)
|
email: EmailStr = Field(unique=True, index=True, max_length=255)
|
||||||
@@ -24,8 +30,11 @@ class UserRegister(SQLModel):
|
|||||||
|
|
||||||
|
|
||||||
# Properties to receive via API on update, all are optional
|
# Properties to receive via API on update, all are optional
|
||||||
class UserUpdate(UserBase):
|
class UserUpdate(SQLModel):
|
||||||
email: EmailStr | None = Field(default=None, max_length=255) # type: ignore
|
email: EmailStr | None = Field(default=None, max_length=255)
|
||||||
|
is_active: bool | None = None
|
||||||
|
is_superuser: bool | None = None
|
||||||
|
full_name: str | None = Field(default=None, max_length=255)
|
||||||
password: str | None = Field(default=None, min_length=8, max_length=128)
|
password: str | None = Field(default=None, min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
@@ -43,12 +52,17 @@ class UpdatePassword(SQLModel):
|
|||||||
class User(UserBase, table=True):
|
class User(UserBase, table=True):
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
hashed_password: str
|
hashed_password: str
|
||||||
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
|
created_at: datetime | None = Field(
|
||||||
|
default_factory=get_datetime_utc,
|
||||||
|
sa_type=DateTime(timezone=True), # type: ignore
|
||||||
|
)
|
||||||
|
items: list[Item] = Relationship(back_populates="owner", cascade_delete=True)
|
||||||
|
|
||||||
|
|
||||||
# Properties to return via API, id is always required
|
# Properties to return via API, id is always required
|
||||||
class UserPublic(UserBase):
|
class UserPublic(UserBase):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class UsersPublic(SQLModel):
|
class UsersPublic(SQLModel):
|
||||||
@@ -68,13 +82,18 @@ class ItemCreate(ItemBase):
|
|||||||
|
|
||||||
|
|
||||||
# Properties to receive on item update
|
# Properties to receive on item update
|
||||||
class ItemUpdate(ItemBase):
|
class ItemUpdate(SQLModel):
|
||||||
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
|
title: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
# Database model, database table inferred from class name
|
# Database model, database table inferred from class name
|
||||||
class Item(ItemBase, table=True):
|
class Item(ItemBase, table=True):
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default_factory=get_datetime_utc,
|
||||||
|
sa_type=DateTime(timezone=True), # type: ignore
|
||||||
|
)
|
||||||
owner_id: uuid.UUID = Field(
|
owner_id: uuid.UUID = Field(
|
||||||
foreign_key="user.id", nullable=False, ondelete="CASCADE"
|
foreign_key="user.id", nullable=False, ondelete="CASCADE"
|
||||||
)
|
)
|
||||||
@@ -85,6 +104,7 @@ class Item(ItemBase, table=True):
|
|||||||
class ItemPublic(ItemBase):
|
class ItemPublic(ItemBase):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
owner_id: uuid.UUID
|
owner_id: uuid.UUID
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class ItemsPublic(SQLModel):
|
class ItemsPublic(SQLModel):
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import emails # type: ignore
|
import emails
|
||||||
import jwt
|
import jwt
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from jwt.exceptions import InvalidTokenError
|
from jwt.exceptions import InvalidTokenError
|
||||||
@@ -24,7 +24,7 @@ class EmailData:
|
|||||||
|
|
||||||
def render_email_template(*, template_name: str, context: dict[str, Any]) -> str:
|
def render_email_template(*, template_name: str, context: dict[str, Any]) -> str:
|
||||||
template_str = (
|
template_str = (
|
||||||
Path(__file__).parent / "email-templates" / "build" / template_name
|
Path(__file__).parent / "email-templates" / template_name
|
||||||
).read_text()
|
).read_text()
|
||||||
html_content = Template(template_str).render(context)
|
html_content = Template(template_str).render(context)
|
||||||
return html_content
|
return html_content
|
||||||
@@ -37,7 +37,8 @@ def send_email(
|
|||||||
html_content: str = "",
|
html_content: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
assert settings.emails_enabled, "no provided configuration for email variables"
|
assert settings.emails_enabled, "no provided configuration for email variables"
|
||||||
message = emails.Message(
|
assert settings.EMAILS_FROM_EMAIL # For type checker
|
||||||
|
message = emails.message.Message(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
html=html_content,
|
html=html_content,
|
||||||
mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL),
|
mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL),
|
||||||
@@ -102,7 +103,7 @@ def generate_new_account_email(
|
|||||||
|
|
||||||
def generate_password_reset_token(email: str) -> str:
|
def generate_password_reset_token(email: str) -> str:
|
||||||
delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
|
delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(UTC)
|
||||||
expires = now + delta
|
expires = now + delta
|
||||||
exp = expires.timestamp()
|
exp = expires.timestamp()
|
||||||
encoded_jwt = jwt.encode(
|
encoded_jwt = jwt.encode(
|
||||||
|
|||||||
+22
-19
@@ -2,34 +2,31 @@
|
|||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = ""
|
description = ""
|
||||||
requires-python = ">=3.10,<4.0"
|
requires-python = ">=3.14,<4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]<1.0.0,>=0.114.2",
|
"fastapi[standard]>=0.141.1,<1.0.0",
|
||||||
"python-multipart<1.0.0,>=0.0.7",
|
"python-multipart<1.0.0,>=0.0.27",
|
||||||
"email-validator<3.0.0.0,>=2.1.0.post1",
|
"email-validator<3.0.0.0,>=2.1.0.post1",
|
||||||
"passlib[bcrypt]<2.0.0,>=1.7.4",
|
"tenacity<10.0.0,>=8.2.3",
|
||||||
"tenacity<9.0.0,>=8.2.3",
|
|
||||||
"pydantic>2.0",
|
"pydantic>2.0",
|
||||||
"emails<1.0,>=0.6",
|
"emails>=1.1.2,<2.0",
|
||||||
"jinja2<4.0.0,>=3.1.4",
|
"jinja2<4.0.0,>=3.1.4",
|
||||||
"alembic<2.0.0,>=1.12.1",
|
"alembic<2.0.0,>=1.12.1",
|
||||||
"httpx<1.0.0,>=0.25.1",
|
"httpx<1.0.0,>=0.25.1",
|
||||||
"psycopg[binary]<4.0.0,>=3.1.13",
|
"psycopg[binary]>=3.3.4,<4.0.0",
|
||||||
"sqlmodel<1.0.0,>=0.0.21",
|
"sqlmodel>=0.0.39,<1.0.0",
|
||||||
# Pin bcrypt until passlib supports the latest
|
|
||||||
"bcrypt==4.3.0",
|
|
||||||
"pydantic-settings<3.0.0,>=2.2.1",
|
"pydantic-settings<3.0.0,>=2.2.1",
|
||||||
"sentry-sdk[fastapi]<2.0.0,>=1.40.6",
|
"sentry-sdk[fastapi]>=2.66.1,<3.0.0",
|
||||||
"pyjwt<3.0.0,>=2.8.0",
|
"pyjwt<3.0.0,>=2.13.0",
|
||||||
|
"pwdlib[argon2,bcrypt]>=0.3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[dependency-groups]
|
||||||
dev-dependencies = [
|
dev = [
|
||||||
"pytest<8.0.0,>=7.4.3",
|
"pytest<10.0.0,>=7.4.3",
|
||||||
"mypy<2.0.0,>=1.8.0",
|
"mypy<3.0.0,>=1.8.0",
|
||||||
|
"ty>=0.0.25",
|
||||||
"ruff<1.0.0,>=0.2.2",
|
"ruff<1.0.0,>=0.2.2",
|
||||||
"pre-commit<4.0.0,>=3.6.2",
|
|
||||||
"types-passlib<2.0.0.0,>=1.7.7.20240106",
|
|
||||||
"coverage<8.0.0,>=7.4.3",
|
"coverage<8.0.0,>=7.4.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -42,7 +39,7 @@ strict = true
|
|||||||
exclude = ["venv", ".venv", "alembic"]
|
exclude = ["venv", ".venv", "alembic"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py310"
|
target-version = "py314"
|
||||||
exclude = ["alembic"]
|
exclude = ["alembic"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
@@ -78,3 +75,9 @@ sort = "-Cover"
|
|||||||
|
|
||||||
[tool.coverage.html]
|
[tool.coverage.html]
|
||||||
show_contexts = true
|
show_contexts = true
|
||||||
|
|
||||||
|
[tool.ty.terminal]
|
||||||
|
error-on-warning = true
|
||||||
|
|
||||||
|
[tool.fastapi]
|
||||||
|
entrypoint = "app.main:app"
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ set -e
|
|||||||
set -x
|
set -x
|
||||||
|
|
||||||
mypy app
|
mypy app
|
||||||
|
ty check app
|
||||||
ruff check app
|
ruff check app
|
||||||
ruff format app --check
|
ruff format app --check
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
set -e
|
set -e
|
||||||
set -x
|
set -x
|
||||||
|
|
||||||
coverage run -m pytest tests/
|
FASTAPI_ENV=development coverage run -m pytest tests/
|
||||||
coverage report
|
coverage report
|
||||||
coverage html --title "${@-coverage}"
|
coverage html --title "${@-coverage}"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ def test_read_item_not_enough_permissions(
|
|||||||
f"{settings.API_V1_STR}/items/{item.id}",
|
f"{settings.API_V1_STR}/items/{item.id}",
|
||||||
headers=normal_user_token_headers,
|
headers=normal_user_token_headers,
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 403
|
||||||
content = response.json()
|
content = response.json()
|
||||||
assert content["detail"] == "Not enough permissions"
|
assert content["detail"] == "Not enough permissions"
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ def test_update_item_not_enough_permissions(
|
|||||||
headers=normal_user_token_headers,
|
headers=normal_user_token_headers,
|
||||||
json=data,
|
json=data,
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 403
|
||||||
content = response.json()
|
content = response.json()
|
||||||
assert content["detail"] == "Not enough permissions"
|
assert content["detail"] == "Not enough permissions"
|
||||||
|
|
||||||
@@ -159,6 +159,6 @@ def test_delete_item_not_enough_permissions(
|
|||||||
f"{settings.API_V1_STR}/items/{item.id}",
|
f"{settings.API_V1_STR}/items/{item.id}",
|
||||||
headers=normal_user_token_headers,
|
headers=normal_user_token_headers,
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 403
|
||||||
content = response.json()
|
content = response.json()
|
||||||
assert content["detail"] == "Not enough permissions"
|
assert content["detail"] == "Not enough permissions"
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from pwdlib.hashers.bcrypt import BcryptHasher
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import verify_password
|
from app.core.security import get_password_hash, verify_password
|
||||||
from app.crud import create_user
|
from app.crud import create_user
|
||||||
from app.models import UserCreate
|
from app.models import User, UserCreate
|
||||||
from app.utils import generate_password_reset_token
|
from app.utils import generate_password_reset_token
|
||||||
from tests.utils.user import user_authentication_headers
|
from tests.utils.user import user_authentication_headers
|
||||||
from tests.utils.utils import random_email, random_lower_string
|
from tests.utils.utils import random_email, random_lower_string
|
||||||
@@ -58,7 +59,9 @@ def test_recovery_password(
|
|||||||
headers=normal_user_token_headers,
|
headers=normal_user_token_headers,
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json() == {"message": "Password recovery email sent"}
|
assert r.json() == {
|
||||||
|
"message": "If that email is registered, we sent a password recovery link"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_password_user_not_exits(
|
def test_recovery_password_user_not_exits(
|
||||||
@@ -69,7 +72,11 @@ def test_recovery_password_user_not_exits(
|
|||||||
f"{settings.API_V1_STR}/password-recovery/{email}",
|
f"{settings.API_V1_STR}/password-recovery/{email}",
|
||||||
headers=normal_user_token_headers,
|
headers=normal_user_token_headers,
|
||||||
)
|
)
|
||||||
assert r.status_code == 404
|
# Should return 200 with generic message to prevent email enumeration attacks
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {
|
||||||
|
"message": "If that email is registered, we sent a password recovery link"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_reset_password(client: TestClient, db: Session) -> None:
|
def test_reset_password(client: TestClient, db: Session) -> None:
|
||||||
@@ -99,7 +106,8 @@ def test_reset_password(client: TestClient, db: Session) -> None:
|
|||||||
assert r.json() == {"message": "Password updated successfully"}
|
assert r.json() == {"message": "Password updated successfully"}
|
||||||
|
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
assert verify_password(new_password, user.hashed_password)
|
verified, _ = verify_password(new_password, user.hashed_password)
|
||||||
|
assert verified
|
||||||
|
|
||||||
|
|
||||||
def test_reset_password_invalid_token(
|
def test_reset_password_invalid_token(
|
||||||
@@ -116,3 +124,68 @@ def test_reset_password_invalid_token(
|
|||||||
assert "detail" in response
|
assert "detail" in response
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert response["detail"] == "Invalid token"
|
assert response["detail"] == "Invalid token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_with_bcrypt_password_upgrades_to_argon2(
|
||||||
|
client: TestClient, db: Session
|
||||||
|
) -> None:
|
||||||
|
"""Test that logging in with a bcrypt password hash upgrades it to argon2."""
|
||||||
|
email = random_email()
|
||||||
|
password = random_lower_string()
|
||||||
|
|
||||||
|
# Create a bcrypt hash directly (simulating legacy password)
|
||||||
|
bcrypt_hasher = BcryptHasher()
|
||||||
|
bcrypt_hash = bcrypt_hasher.hash(password)
|
||||||
|
assert bcrypt_hash.startswith("$2") # bcrypt hashes start with $2
|
||||||
|
|
||||||
|
user = User(email=email, hashed_password=bcrypt_hash, is_active=True)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
assert user.hashed_password.startswith("$2")
|
||||||
|
|
||||||
|
login_data = {"username": email, "password": password}
|
||||||
|
r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
|
||||||
|
assert r.status_code == 200
|
||||||
|
tokens = r.json()
|
||||||
|
assert "access_token" in tokens
|
||||||
|
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
# Verify the hash was upgraded to argon2
|
||||||
|
assert user.hashed_password.startswith("$argon2")
|
||||||
|
|
||||||
|
verified, updated_hash = verify_password(password, user.hashed_password)
|
||||||
|
assert verified
|
||||||
|
# Should not need another update since it's already argon2
|
||||||
|
assert updated_hash is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_with_argon2_password_keeps_hash(client: TestClient, db: Session) -> None:
|
||||||
|
"""Test that logging in with an argon2 password hash does not update it."""
|
||||||
|
email = random_email()
|
||||||
|
password = random_lower_string()
|
||||||
|
|
||||||
|
# Create an argon2 hash (current default)
|
||||||
|
argon2_hash = get_password_hash(password)
|
||||||
|
assert argon2_hash.startswith("$argon2")
|
||||||
|
|
||||||
|
# Create user with argon2 hash
|
||||||
|
user = User(email=email, hashed_password=argon2_hash, is_active=True)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
original_hash = user.hashed_password
|
||||||
|
|
||||||
|
login_data = {"username": email, "password": password}
|
||||||
|
r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
|
||||||
|
assert r.status_code == 200
|
||||||
|
tokens = r.json()
|
||||||
|
assert "access_token" in tokens
|
||||||
|
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
assert user.hashed_password == original_hash
|
||||||
|
assert user.hashed_password.startswith("$argon2")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app import crud
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import verify_password
|
from app.core.security import verify_password
|
||||||
from app.models import User, UserCreate
|
from app.models import User, UserCreate
|
||||||
|
from tests.utils.user import create_random_user
|
||||||
from tests.utils.utils import random_email, random_lower_string
|
from tests.utils.utils import random_email, random_lower_string
|
||||||
|
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ def test_create_user_new_email(
|
|||||||
assert user.email == created_user["email"]
|
assert user.email == created_user["email"]
|
||||||
|
|
||||||
|
|
||||||
def test_get_existing_user(
|
def test_get_existing_user_as_superuser(
|
||||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
) -> None:
|
) -> None:
|
||||||
username = random_email()
|
username = random_email()
|
||||||
@@ -75,6 +76,17 @@ def test_get_existing_user(
|
|||||||
assert existing_user.email == api_user["email"]
|
assert existing_user.email == api_user["email"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_non_existing_user_as_superuser(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
r = client.get(
|
||||||
|
f"{settings.API_V1_STR}/users/{uuid.uuid4()}",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json() == {"detail": "User not found"}
|
||||||
|
|
||||||
|
|
||||||
def test_get_existing_user_current_user(client: TestClient, db: Session) -> None:
|
def test_get_existing_user_current_user(client: TestClient, db: Session) -> None:
|
||||||
username = random_email()
|
username = random_email()
|
||||||
password = random_lower_string()
|
password = random_lower_string()
|
||||||
@@ -103,10 +115,28 @@ def test_get_existing_user_current_user(client: TestClient, db: Session) -> None
|
|||||||
|
|
||||||
|
|
||||||
def test_get_existing_user_permissions_error(
|
def test_get_existing_user_permissions_error(
|
||||||
client: TestClient, normal_user_token_headers: dict[str, str]
|
db: Session,
|
||||||
|
client: TestClient,
|
||||||
|
normal_user_token_headers: dict[str, str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
user = create_random_user(db)
|
||||||
|
|
||||||
r = client.get(
|
r = client.get(
|
||||||
f"{settings.API_V1_STR}/users/{uuid.uuid4()}",
|
f"{settings.API_V1_STR}/users/{user.id}",
|
||||||
|
headers=normal_user_token_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json() == {"detail": "The user doesn't have enough privileges"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_non_existing_user_permissions_error(
|
||||||
|
client: TestClient,
|
||||||
|
normal_user_token_headers: dict[str, str],
|
||||||
|
) -> None:
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
r = client.get(
|
||||||
|
f"{settings.API_V1_STR}/users/{user_id}",
|
||||||
headers=normal_user_token_headers,
|
headers=normal_user_token_headers,
|
||||||
)
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
@@ -212,7 +242,8 @@ def test_update_password_me(
|
|||||||
user_db = db.exec(user_query).first()
|
user_db = db.exec(user_query).first()
|
||||||
assert user_db
|
assert user_db
|
||||||
assert user_db.email == settings.FIRST_SUPERUSER
|
assert user_db.email == settings.FIRST_SUPERUSER
|
||||||
assert verify_password(new_password, user_db.hashed_password)
|
verified, _ = verify_password(new_password, user_db.hashed_password)
|
||||||
|
assert verified
|
||||||
|
|
||||||
# Revert to the old password to keep consistency in test
|
# Revert to the old password to keep consistency in test
|
||||||
old_data = {
|
old_data = {
|
||||||
@@ -227,7 +258,10 @@ def test_update_password_me(
|
|||||||
db.refresh(user_db)
|
db.refresh(user_db)
|
||||||
|
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert verify_password(settings.FIRST_SUPERUSER_PASSWORD, user_db.hashed_password)
|
verified, _ = verify_password(
|
||||||
|
settings.FIRST_SUPERUSER_PASSWORD, user_db.hashed_password
|
||||||
|
)
|
||||||
|
assert verified
|
||||||
|
|
||||||
|
|
||||||
def test_update_password_me_incorrect_password(
|
def test_update_password_me_incorrect_password(
|
||||||
@@ -301,7 +335,8 @@ def test_register_user(client: TestClient, db: Session) -> None:
|
|||||||
assert user_db
|
assert user_db
|
||||||
assert user_db.email == username
|
assert user_db.email == username
|
||||||
assert user_db.full_name == full_name
|
assert user_db.full_name == full_name
|
||||||
assert verify_password(password, user_db.hashed_password)
|
verified, _ = verify_password(password, user_db.hashed_password)
|
||||||
|
assert verified
|
||||||
|
|
||||||
|
|
||||||
def test_register_user_already_exists_error(client: TestClient) -> None:
|
def test_register_user_already_exists_error(client: TestClient) -> None:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from tests.utils.utils import get_superuser_token_headers
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def db() -> Generator[Session, None, None]:
|
def db() -> Generator[Session]:
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
init_db(session)
|
init_db(session)
|
||||||
yield session
|
yield session
|
||||||
@@ -25,7 +25,7 @@ def db() -> Generator[Session, None, None]:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def client() -> Generator[TestClient, None, None]:
|
def client() -> Generator[TestClient]:
|
||||||
with TestClient(app) as c:
|
with TestClient(app) as c:
|
||||||
yield c
|
yield c
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from pwdlib.hashers.bcrypt import BcryptHasher
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app import crud
|
from app import crud
|
||||||
@@ -44,9 +45,9 @@ def test_check_if_user_is_active(db: Session) -> None:
|
|||||||
def test_check_if_user_is_active_inactive(db: Session) -> None:
|
def test_check_if_user_is_active_inactive(db: Session) -> None:
|
||||||
email = random_email()
|
email = random_email()
|
||||||
password = random_lower_string()
|
password = random_lower_string()
|
||||||
user_in = UserCreate(email=email, password=password, disabled=True)
|
user_in = UserCreate(email=email, password=password, is_active=False)
|
||||||
user = crud.create_user(session=db, user_create=user_in)
|
user = crud.create_user(session=db, user_create=user_in)
|
||||||
assert user.is_active
|
assert user.is_active is False
|
||||||
|
|
||||||
|
|
||||||
def test_check_if_user_is_superuser(db: Session) -> None:
|
def test_check_if_user_is_superuser(db: Session) -> None:
|
||||||
@@ -88,4 +89,42 @@ def test_update_user(db: Session) -> None:
|
|||||||
user_2 = db.get(User, user.id)
|
user_2 = db.get(User, user.id)
|
||||||
assert user_2
|
assert user_2
|
||||||
assert user.email == user_2.email
|
assert user.email == user_2.email
|
||||||
assert verify_password(new_password, user_2.hashed_password)
|
verified, _ = verify_password(new_password, user_2.hashed_password)
|
||||||
|
assert verified
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticate_user_with_bcrypt_upgrades_to_argon2(db: Session) -> None:
|
||||||
|
"""Test that a user with bcrypt password hash gets upgraded to argon2 on login."""
|
||||||
|
email = random_email()
|
||||||
|
password = random_lower_string()
|
||||||
|
|
||||||
|
# Create a bcrypt hash directly (simulating legacy password)
|
||||||
|
bcrypt_hasher = BcryptHasher()
|
||||||
|
bcrypt_hash = bcrypt_hasher.hash(password)
|
||||||
|
assert bcrypt_hash.startswith("$2") # bcrypt hashes start with $2
|
||||||
|
|
||||||
|
# Create user with bcrypt hash directly in the database
|
||||||
|
user = User(email=email, hashed_password=bcrypt_hash)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
# Verify the hash is bcrypt before authentication
|
||||||
|
assert user.hashed_password.startswith("$2")
|
||||||
|
|
||||||
|
# Authenticate - this should upgrade the hash to argon2
|
||||||
|
authenticated_user = crud.authenticate(session=db, email=email, password=password)
|
||||||
|
assert authenticated_user
|
||||||
|
assert authenticated_user.email == email
|
||||||
|
|
||||||
|
db.refresh(authenticated_user)
|
||||||
|
|
||||||
|
# Verify the hash was upgraded to argon2
|
||||||
|
assert authenticated_user.hashed_password.startswith("$argon2")
|
||||||
|
|
||||||
|
verified, updated_hash = verify_password(
|
||||||
|
password, authenticated_user.hashed_password
|
||||||
|
)
|
||||||
|
assert verified
|
||||||
|
# Should not need another update since it's already argon2
|
||||||
|
assert updated_hash is None
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ def test_init_successful_connection() -> None:
|
|||||||
engine_mock = MagicMock()
|
engine_mock = MagicMock()
|
||||||
|
|
||||||
session_mock = MagicMock()
|
session_mock = MagicMock()
|
||||||
exec_mock = MagicMock(return_value=True)
|
session_mock.__enter__.return_value = session_mock
|
||||||
session_mock.configure_mock(**{"exec.return_value": exec_mock})
|
|
||||||
|
select1 = select(1)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("sqlmodel.Session", return_value=session_mock),
|
patch("app.backend_pre_start.Session", return_value=session_mock),
|
||||||
|
patch("app.backend_pre_start.select", return_value=select1),
|
||||||
patch.object(logger, "info"),
|
patch.object(logger, "info"),
|
||||||
patch.object(logger, "error"),
|
patch.object(logger, "error"),
|
||||||
patch.object(logger, "warn"),
|
patch.object(logger, "warn"),
|
||||||
@@ -24,10 +26,8 @@ def test_init_successful_connection() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
connection_successful = False
|
connection_successful = False
|
||||||
|
|
||||||
assert (
|
assert connection_successful, (
|
||||||
connection_successful
|
"The database connection should be successful and not raise an exception."
|
||||||
), "The database connection should be successful and not raise an exception."
|
)
|
||||||
|
|
||||||
assert session_mock.exec.called_once_with(
|
session_mock.exec.assert_called_once_with(select1)
|
||||||
select(1)
|
|
||||||
), "The session should execute a select statement once."
|
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ def test_init_successful_connection() -> None:
|
|||||||
engine_mock = MagicMock()
|
engine_mock = MagicMock()
|
||||||
|
|
||||||
session_mock = MagicMock()
|
session_mock = MagicMock()
|
||||||
exec_mock = MagicMock(return_value=True)
|
session_mock.__enter__.return_value = session_mock
|
||||||
session_mock.configure_mock(**{"exec.return_value": exec_mock})
|
|
||||||
|
select1 = select(1)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("sqlmodel.Session", return_value=session_mock),
|
patch("app.tests_pre_start.Session", return_value=session_mock),
|
||||||
|
patch("app.tests_pre_start.select", return_value=select1),
|
||||||
patch.object(logger, "info"),
|
patch.object(logger, "info"),
|
||||||
patch.object(logger, "error"),
|
patch.object(logger, "error"),
|
||||||
patch.object(logger, "warn"),
|
patch.object(logger, "warn"),
|
||||||
@@ -24,10 +26,8 @@ def test_init_successful_connection() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
connection_successful = False
|
connection_successful = False
|
||||||
|
|
||||||
assert (
|
assert connection_successful, (
|
||||||
connection_successful
|
"The database connection should be successful and not raise an exception."
|
||||||
), "The database connection should be successful and not raise an exception."
|
)
|
||||||
|
|
||||||
assert session_mock.exec.called_once_with(
|
session_mock.exec.assert_called_once_with(select1)
|
||||||
select(1)
|
|
||||||
), "The session should execute a select statement once."
|
|
||||||
|
|||||||
Generated
-1659
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
# Listen on port 80, default for HTTP, necessary to redirect to HTTPS
|
||||||
|
- "80:80"
|
||||||
|
# Listen on port 443, default for HTTPS
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
# Mount the volume to store the certificates
|
||||||
|
- traefik-certificates:/certificates
|
||||||
|
command:
|
||||||
|
# Enable Docker in Traefik, so that it reads labels from Docker services
|
||||||
|
- --providers.docker
|
||||||
|
# Do not expose all Docker services, only the ones explicitly exposed
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
# Create an entrypoint "http" listening on port 80
|
||||||
|
- --entrypoints.http.address=:80
|
||||||
|
# Redirect all HTTP traffic to HTTPS
|
||||||
|
- --entrypoints.http.http.redirections.entrypoint.to=https
|
||||||
|
- --entrypoints.http.http.redirections.entrypoint.scheme=https
|
||||||
|
# Create an entrypoint "https" listening on port 443
|
||||||
|
- --entrypoints.https.address=:443
|
||||||
|
# Store the Let's Encrypt certificates in the mounted volume
|
||||||
|
- --certificatesresolvers.le.acme.storage=/certificates/acme.json
|
||||||
|
# Use the TLS Challenge for Let's Encrypt
|
||||||
|
- --certificatesresolvers.le.acme.tlschallenge=true
|
||||||
|
# Enable the access log, with HTTP requests
|
||||||
|
- --accesslog
|
||||||
|
# Enable the Traefik log, for configurations and errors
|
||||||
|
- --log
|
||||||
|
|
||||||
|
db:
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
restart: always
|
||||||
|
labels:
|
||||||
|
# Route HTTPS traffic for the Adminer subdomain
|
||||||
|
- traefik.http.routers.adminer-https.rule=Host(`adminer.${DOMAIN:?Variable not set}`)
|
||||||
|
- traefik.http.routers.adminer-https.entrypoints=https
|
||||||
|
- traefik.http.routers.adminer-https.tls=true
|
||||||
|
# Use the Let's Encrypt resolver
|
||||||
|
- traefik.http.routers.adminer-https.tls.certresolver=le
|
||||||
|
|
||||||
|
backend:
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
FRONTEND_HOST: https://${DOMAIN:?Variable not set}
|
||||||
|
labels:
|
||||||
|
# Route HTTPS traffic for this domain
|
||||||
|
- traefik.http.routers.backend-https.rule=Host(`${DOMAIN:?Variable not set}`)
|
||||||
|
- traefik.http.routers.backend-https.entrypoints=https
|
||||||
|
- traefik.http.routers.backend-https.tls=true
|
||||||
|
# Use the Let's Encrypt resolver
|
||||||
|
- traefik.http.routers.backend-https.tls.certresolver=le
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Create a volume to store the certificates, even if the container is recreated
|
||||||
|
traefik-certificates:
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
image: traefik:3.6
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "8090:8080"
|
||||||
|
# Duplicate the command from compose.yml to add --api.insecure=true
|
||||||
|
command:
|
||||||
|
# Enable Docker in Traefik, so that it reads labels from Docker services
|
||||||
|
- --providers.docker
|
||||||
|
# Do not expose all Docker services, only the ones explicitly exposed
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
# Create an entrypoint "http" listening on port 80
|
||||||
|
- --entrypoints.http.address=:80
|
||||||
|
# Enable the access log, with HTTP requests
|
||||||
|
- --accesslog
|
||||||
|
# Enable the Traefik log, for configurations and errors
|
||||||
|
- --log
|
||||||
|
# Enable debug logging for local development
|
||||||
|
- --log.level=DEBUG
|
||||||
|
# Enable the Dashboard and API
|
||||||
|
- --api
|
||||||
|
# Enable the Dashboard and API in insecure mode for local development
|
||||||
|
- --api.insecure=true
|
||||||
|
db:
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
# command: sleep infinity # Infinite loop to keep container alive doing nothing
|
||||||
|
command:
|
||||||
|
- fastapi
|
||||||
|
- dev
|
||||||
|
- --host
|
||||||
|
- "0.0.0.0"
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- path: ./backend
|
||||||
|
action: sync
|
||||||
|
target: /app/backend
|
||||||
|
ignore:
|
||||||
|
- .venv
|
||||||
|
- path: ./backend/pyproject.toml
|
||||||
|
action: rebuild
|
||||||
|
- path: ./frontend
|
||||||
|
action: rebuild
|
||||||
|
ignore:
|
||||||
|
- ./frontend/node_modules
|
||||||
|
- ./frontend/dist
|
||||||
|
- ./frontend/blob-report
|
||||||
|
- ./frontend/test-results
|
||||||
|
# TODO: remove once coverage is done locally
|
||||||
|
volumes:
|
||||||
|
- ./backend/htmlcov:/app/backend/htmlcov
|
||||||
|
environment:
|
||||||
|
FASTAPI_ENV: "development"
|
||||||
|
SMTP_HOST: "mailcatcher"
|
||||||
|
SMTP_PORT: "1025"
|
||||||
|
SMTP_TLS: "false"
|
||||||
|
|
||||||
|
mailcatcher:
|
||||||
|
image: schickling/mailcatcher
|
||||||
|
ports:
|
||||||
|
- "1080:1080"
|
||||||
|
- "1025:1025"
|
||||||
|
|
||||||
|
playwright:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile.playwright
|
||||||
|
args:
|
||||||
|
- NODE_ENV=production
|
||||||
|
ipc: host
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- mailcatcher
|
||||||
|
environment:
|
||||||
|
- FIRST_SUPERUSER=${FIRST_SUPERUSER:?Variable not set}
|
||||||
|
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD:?Variable not set}
|
||||||
|
- PLAYWRIGHT_BASE_URL=http://backend:8000
|
||||||
|
- VITE_API_URL=http://backend:8000
|
||||||
|
- MAILCATCHER_HOST=http://mailcatcher:1080
|
||||||
|
# For the reports when run locally
|
||||||
|
- PLAYWRIGHT_HTML_HOST=0.0.0.0
|
||||||
|
- CI=${CI:-}
|
||||||
|
volumes:
|
||||||
|
- ./frontend/blob-report:/app/frontend/blob-report
|
||||||
|
- ./frontend/test-results:/app/frontend/test-results
|
||||||
|
ports:
|
||||||
|
- 9323:9323
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
proxy:
|
||||||
|
image: traefik:3.6
|
||||||
|
volumes:
|
||||||
|
# Add Docker as a mounted volume, so that Traefik can read the labels of other services
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
command:
|
||||||
|
# Enable Docker in Traefik, so that it reads labels from Docker services
|
||||||
|
- --providers.docker
|
||||||
|
# Do not expose all Docker services, only the ones explicitly exposed
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
# Create an entrypoint "http" listening on port 80
|
||||||
|
- --entrypoints.http.address=:80
|
||||||
|
# Enable the access log, with HTTP requests
|
||||||
|
- --accesslog
|
||||||
|
# Enable the Traefik log, for configurations and errors
|
||||||
|
- --log
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:18
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
timeout: 10s
|
||||||
|
volumes:
|
||||||
|
- app-db-data:/var/lib/postgresql
|
||||||
|
environment:
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Variable not set}
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER:?Variable not set}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB:?Variable not set}
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
image: adminer
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
- ADMINER_DESIGN=pepa-linha-dark
|
||||||
|
labels:
|
||||||
|
# Enable Traefik for this service
|
||||||
|
- traefik.enable=true
|
||||||
|
# Route HTTP traffic for the Adminer subdomain
|
||||||
|
- traefik.http.routers.adminer-http.rule=Host(`adminer.${DOMAIN:-localhost}`)
|
||||||
|
- traefik.http.routers.adminer-http.entrypoints=http
|
||||||
|
# Define the port inside of the Docker service to use
|
||||||
|
- traefik.http.services.adminer.loadbalancer.server.port=8080
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: backend:latest
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
environment:
|
||||||
|
PROJECT_NAME: ${PROJECT_NAME:?Variable not set}
|
||||||
|
SECRET_KEY: ${SECRET_KEY:?Variable not set}
|
||||||
|
FIRST_SUPERUSER: ${FIRST_SUPERUSER:?Variable not set}
|
||||||
|
FIRST_SUPERUSER_PASSWORD: ${FIRST_SUPERUSER_PASSWORD:?Variable not set}
|
||||||
|
SMTP_HOST: ${SMTP_HOST}
|
||||||
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
EMAILS_FROM_EMAIL: ${EMAILS_FROM_EMAIL}
|
||||||
|
POSTGRES_SERVER: db
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:?Variable not set}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:?Variable not set}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Variable not set}
|
||||||
|
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
|
labels:
|
||||||
|
# Enable Traefik for this service
|
||||||
|
- traefik.enable=true
|
||||||
|
|
||||||
|
# Define the port inside of the Docker service to use
|
||||||
|
- traefik.http.services.backend.loadbalancer.server.port=8000
|
||||||
|
|
||||||
|
# Route HTTP traffic for this domain
|
||||||
|
- traefik.http.routers.backend-http.rule=Host(`${DOMAIN:-localhost}`)
|
||||||
|
- traefik.http.routers.backend-http.entrypoints=http
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app-db-data:
|
||||||
-100
@@ -1,100 +0,0 @@
|
|||||||
project_name:
|
|
||||||
type: str
|
|
||||||
help: The name of the project, shown to API users (in .env)
|
|
||||||
default: FastAPI Project
|
|
||||||
|
|
||||||
stack_name:
|
|
||||||
type: str
|
|
||||||
help: The name of the stack used for Docker Compose labels (no spaces) (in .env)
|
|
||||||
default: fastapi-project
|
|
||||||
|
|
||||||
secret_key:
|
|
||||||
type: str
|
|
||||||
help: |
|
|
||||||
'The secret key for the project, used for security,
|
|
||||||
stored in .env, you can generate one with:
|
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
first_superuser:
|
|
||||||
type: str
|
|
||||||
help: The email of the first superuser (in .env)
|
|
||||||
default: admin@example.com
|
|
||||||
|
|
||||||
first_superuser_password:
|
|
||||||
type: str
|
|
||||||
help: The password of the first superuser (in .env)
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
smtp_host:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server host to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
smtp_user:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server user to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
smtp_password:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server password to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
emails_from_email:
|
|
||||||
type: str
|
|
||||||
help: The email account to send emails from, you can set it later in .env
|
|
||||||
default: info@example.com
|
|
||||||
|
|
||||||
postgres_password:
|
|
||||||
type: str
|
|
||||||
help: |
|
|
||||||
'The password for the PostgreSQL database, stored in .env,
|
|
||||||
you can generate one with:
|
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
sentry_dsn:
|
|
||||||
type: str
|
|
||||||
help: The DSN for Sentry, if you are using it, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
_exclude:
|
|
||||||
# Global
|
|
||||||
- .vscode
|
|
||||||
- .mypy_cache
|
|
||||||
# Python
|
|
||||||
- __pycache__
|
|
||||||
- app.egg-info
|
|
||||||
- "*.pyc"
|
|
||||||
- .mypy_cache
|
|
||||||
- .coverage
|
|
||||||
- htmlcov
|
|
||||||
- .cache
|
|
||||||
- .venv
|
|
||||||
# Frontend
|
|
||||||
# Logs
|
|
||||||
- logs
|
|
||||||
- "*.log"
|
|
||||||
- npm-debug.log*
|
|
||||||
- yarn-debug.log*
|
|
||||||
- yarn-error.log*
|
|
||||||
- pnpm-debug.log*
|
|
||||||
- lerna-debug.log*
|
|
||||||
- node_modules
|
|
||||||
- dist
|
|
||||||
- dist-ssr
|
|
||||||
- "*.local"
|
|
||||||
# Editor directories and files
|
|
||||||
- .idea
|
|
||||||
- .DS_Store
|
|
||||||
- "*.suo"
|
|
||||||
- "*.ntvs*"
|
|
||||||
- "*.njsproj"
|
|
||||||
- "*.sln"
|
|
||||||
- "*.sw?"
|
|
||||||
|
|
||||||
_answers_file: .copier/.copier-answers.yml
|
|
||||||
|
|
||||||
_tasks:
|
|
||||||
- ["{{ _copier_python }}", .copier/update_dotenv.py]
|
|
||||||
+97
-157
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
You can deploy the project using Docker Compose to a remote server.
|
You can deploy the project using Docker Compose to a remote server.
|
||||||
|
|
||||||
This project expects you to have a Traefik proxy handling communication to the outside world and HTTPS certificates.
|
The deployment Docker Compose configuration includes Traefik to handle HTTPS and route incoming traffic to the application.
|
||||||
|
|
||||||
You can use CI/CD (continuous integration and continuous deployment) systems to deploy automatically, there are already configurations to do it with GitHub Actions.
|
You can use CI/CD (continuous integration and continuous deployment) systems to deploy automatically, there are already configurations to do it with GitHub Actions.
|
||||||
|
|
||||||
@@ -10,147 +10,28 @@ But you have to configure a couple things first. 🤓
|
|||||||
|
|
||||||
## Preparation
|
## Preparation
|
||||||
|
|
||||||
* Have a remote server ready and available.
|
* Have a remote server ready and available. Use a separate server for each environment, for example one for staging and one for production.
|
||||||
* Configure the DNS records of your domain to point to the IP of the server you just created.
|
* Configure DNS records pointing to the server for the application domain and any supporting service subdomains you want to expose, e.g. `fastapi-project.example.com` and `adminer.fastapi-project.example.com`.
|
||||||
* Configure a wildcard subdomain for your domain, so that you can have multiple subdomains for different services, e.g. `*.fastapi-project.example.com`. This will be useful for accessing different components, like `dashboard.fastapi-project.example.com`, `api.fastapi-project.example.com`, `traefik.fastapi-project.example.com`, `adminer.fastapi-project.example.com`, etc. And also for `staging`, like `dashboard.staging.fastapi-project.example.com`, `adminer.staging.fastapi-project.example.com`, etc.
|
|
||||||
* Install and configure [Docker](https://docs.docker.com/engine/install/) on the remote server (Docker Engine, not Docker Desktop).
|
* Install and configure [Docker](https://docs.docker.com/engine/install/) on the remote server (Docker Engine, not Docker Desktop).
|
||||||
|
|
||||||
## Public Traefik
|
|
||||||
|
|
||||||
We need a Traefik proxy to handle incoming connections and HTTPS certificates.
|
|
||||||
|
|
||||||
You need to do these next steps only once.
|
|
||||||
|
|
||||||
### Traefik Docker Compose
|
|
||||||
|
|
||||||
* Create a remote directory to store your Traefik Docker Compose file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p /root/code/traefik-public/
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy the Traefik Docker Compose file to your server. You could do it by running the command `rsync` in your local terminal:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -a docker-compose.traefik.yml root@your-server.example.com:/root/code/traefik-public/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Traefik Public Network
|
|
||||||
|
|
||||||
This Traefik will expect a Docker "public network" named `traefik-public` to communicate with your stack(s).
|
|
||||||
|
|
||||||
This way, there will be a single public Traefik proxy that handles the communication (HTTP and HTTPS) with the outside world, and then behind that, you could have one or more stacks with different domains, even if they are on the same single server.
|
|
||||||
|
|
||||||
To create a Docker "public network" named `traefik-public` run the following command in your remote server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker network create traefik-public
|
|
||||||
```
|
|
||||||
|
|
||||||
### Traefik Environment Variables
|
|
||||||
|
|
||||||
The Traefik Docker Compose file expects some environment variables to be set in your terminal before starting it. You can do it by running the following commands in your remote server.
|
|
||||||
|
|
||||||
* Create the username for HTTP Basic Auth, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export USERNAME=admin
|
|
||||||
```
|
|
||||||
|
|
||||||
* Create an environment variable with the password for HTTP Basic Auth, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export PASSWORD=changethis
|
|
||||||
```
|
|
||||||
|
|
||||||
* Use openssl to generate the "hashed" version of the password for HTTP Basic Auth and store it in an environment variable:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export HASHED_PASSWORD=$(openssl passwd -apr1 $PASSWORD)
|
|
||||||
```
|
|
||||||
|
|
||||||
To verify that the hashed password is correct, you can print it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo $HASHED_PASSWORD
|
|
||||||
```
|
|
||||||
|
|
||||||
* Create an environment variable with the domain name for your server, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export DOMAIN=fastapi-project.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
* Create an environment variable with the email for Let's Encrypt, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export EMAIL=admin@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: you need to set a different email, an email `@example.com` won't work.
|
|
||||||
|
|
||||||
### Start the Traefik Docker Compose
|
|
||||||
|
|
||||||
Go to the directory where you copied the Traefik Docker Compose file in your remote server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /root/code/traefik-public/
|
|
||||||
```
|
|
||||||
|
|
||||||
Now with the environment variables set and the `docker-compose.traefik.yml` in place, you can start the Traefik Docker Compose running the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.traefik.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deploy the FastAPI Project
|
## Deploy the FastAPI Project
|
||||||
|
|
||||||
Now that you have Traefik in place you can deploy your FastAPI project with Docker Compose.
|
You can deploy your FastAPI project with Docker Compose.
|
||||||
|
|
||||||
**Note**: You might want to jump ahead to the section about Continuous Deployment with GitHub Actions.
|
**Note**: You might want to jump ahead to the section about Continuous Deployment with GitHub Actions.
|
||||||
|
|
||||||
|
## Copy the Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rsync -av --exclude=".git/" --filter=":- .gitignore" ./ root@your-server.example.com:/root/code/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `--filter=":- .gitignore"` tells `rsync` to use the same rules as git, ignore files ignored by git, like the Python virtual environment.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
You need to set some environment variables first.
|
You need to set some environment variables first.
|
||||||
|
|
||||||
Set the `ENVIRONMENT`, by default `local` (for development), but when deploying to a server you would put something like `staging` or `production`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ENVIRONMENT=production
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the `DOMAIN`, by default `localhost` (for development), but when deploying you would use your own domain, for example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export DOMAIN=fastapi-project.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
You can set several variables, like:
|
|
||||||
|
|
||||||
* `PROJECT_NAME`: The name of the project, used in the API for the docs and emails.
|
|
||||||
* `STACK_NAME`: The name of the stack used for Docker Compose labels and project name, this should be different for `staging`, `production`, etc. You could use the same domain replacing dots with dashes, e.g. `fastapi-project-example-com` and `staging-fastapi-project-example-com`.
|
|
||||||
* `BACKEND_CORS_ORIGINS`: A list of allowed CORS origins separated by commas.
|
|
||||||
* `SECRET_KEY`: The secret key for the FastAPI project, used to sign tokens.
|
|
||||||
* `FIRST_SUPERUSER`: The email of the first superuser, this superuser will be the one that can create new users.
|
|
||||||
* `FIRST_SUPERUSER_PASSWORD`: The password of the first superuser.
|
|
||||||
* `SMTP_HOST`: The SMTP server host to send emails, this would come from your email provider (E.g. Mailgun, Sparkpost, Sendgrid, etc).
|
|
||||||
* `SMTP_USER`: The SMTP server user to send emails.
|
|
||||||
* `SMTP_PASSWORD`: The SMTP server password to send emails.
|
|
||||||
* `EMAILS_FROM_EMAIL`: The email account to send emails from.
|
|
||||||
* `POSTGRES_SERVER`: The hostname of the PostgreSQL server. You can leave the default of `db`, provided by the same Docker Compose. You normally wouldn't need to change this unless you are using a third-party provider.
|
|
||||||
* `POSTGRES_PORT`: The port of the PostgreSQL server. You can leave the default. You normally wouldn't need to change this unless you are using a third-party provider.
|
|
||||||
* `POSTGRES_PASSWORD`: The Postgres password.
|
|
||||||
* `POSTGRES_USER`: The Postgres user, you can leave the default.
|
|
||||||
* `POSTGRES_DB`: The database name to use for this application. You can leave the default of `app`.
|
|
||||||
* `SENTRY_DSN`: The DSN for Sentry, if you are using it.
|
|
||||||
|
|
||||||
## GitHub Actions Environment Variables
|
|
||||||
|
|
||||||
There are some environment variables only used by GitHub Actions that you can configure:
|
|
||||||
|
|
||||||
* `LATEST_CHANGES`: Used by the GitHub Action [latest-changes](https://github.com/tiangolo/latest-changes) to automatically add release notes based on the PRs merged. It's a personal access token, read the docs for details.
|
|
||||||
* `SMOKESHOW_AUTH_KEY`: Used to handle and publish the code coverage using [Smokeshow](https://github.com/samuelcolvin/smokeshow), follow their instructions to create a (free) Smokeshow key.
|
|
||||||
|
|
||||||
### Generate secret keys
|
### Generate secret keys
|
||||||
|
|
||||||
Some environment variables in the `.env` file have a default value of `changethis`.
|
Some environment variables in the `.env` file have a default value of `changethis`.
|
||||||
@@ -163,23 +44,71 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|||||||
|
|
||||||
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
|
||||||
|
Set the `DOMAIN` to your own domain, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DOMAIN=fastapi-project.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
The deployment Docker Compose configuration also uses `DOMAIN` to set the public frontend URL used in links generated by the backend.
|
||||||
|
|
||||||
|
Set the `POSTGRES_PASSWORD` to a secure value:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export POSTGRES_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the `SECRET_KEY`, used to sign tokens, to a secure value:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the `FIRST_SUPERUSER_PASSWORD` to a secure value:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FIRST_SUPERUSER_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can set several other environment variables:
|
||||||
|
|
||||||
|
* `PROJECT_NAME`: The name of the project, used in the API for the docs and emails.
|
||||||
|
* `FIRST_SUPERUSER`: The email of the first superuser, this superuser will be the one that can create new users.
|
||||||
|
* `SMTP_HOST`: The SMTP server host to send emails, this would come from your email provider (E.g. Mailgun, Sparkpost, Sendgrid, etc).
|
||||||
|
* `SMTP_USER`: The SMTP server user to send emails.
|
||||||
|
* `SMTP_PASSWORD`: The SMTP server password to send emails.
|
||||||
|
* `EMAILS_FROM_EMAIL`: The email account to send emails from.
|
||||||
|
* `POSTGRES_USER`: The Postgres user, you can leave the default.
|
||||||
|
* `POSTGRES_DB`: The database name to use for this application. You can leave the default of `app`.
|
||||||
|
* `SENTRY_DSN`: The DSN for Sentry, if you are using it.
|
||||||
|
|
||||||
|
## GitHub Actions Environment Variables
|
||||||
|
|
||||||
|
There are some environment variables only used by GitHub Actions that you can configure:
|
||||||
|
|
||||||
|
* `LATEST_CHANGES`: Used by the GitHub Action [latest-changes](https://github.com/tiangolo/latest-changes) to automatically add release notes based on the PRs merged. It's a personal access token, read the docs for details.
|
||||||
|
* `SMOKESHOW_AUTH_KEY`: Used to handle and publish the code coverage using [Smokeshow](https://github.com/samuelcolvin/smokeshow), follow their instructions to create a (free) Smokeshow key.
|
||||||
|
|
||||||
### Deploy with Docker Compose
|
### Deploy with Docker Compose
|
||||||
|
|
||||||
With the environment variables in place, you can deploy with Docker Compose:
|
With the environment variables in place, you can deploy with Docker Compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.yml up -d
|
cd /root/code/app/
|
||||||
|
docker compose -f compose.yml -f compose.deploy.yml build
|
||||||
|
docker compose -f compose.yml -f compose.deploy.yml run --rm backend bash scripts/prestart.sh
|
||||||
|
docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
For production you wouldn't want to have the overrides in `docker-compose.override.yml`, that's why we explicitly specify `docker-compose.yml` as the file to use.
|
The `compose.deploy.yml` file adds the deployment settings to the shared configuration in `compose.yml`, including HTTPS and automatic certificate handling. Explicitly listing these files also excludes the local development settings in `compose.override.yml`.
|
||||||
|
|
||||||
## Continuous Deployment (CD)
|
## Continuous Deployment (CD)
|
||||||
|
|
||||||
You can use GitHub Actions to deploy your project automatically. 😎
|
You can use GitHub Actions to deploy your project automatically. 😎
|
||||||
|
|
||||||
You can have multiple environment deployments.
|
There are already two environment deployments configured, `staging` and `production`. Each environment should be deployed to a separate server. 🚀
|
||||||
|
|
||||||
There are already two environments configured, `staging` and `production`. 🚀
|
|
||||||
|
|
||||||
### Install GitHub Actions Runner
|
### Install GitHub Actions Runner
|
||||||
|
|
||||||
@@ -253,23 +182,32 @@ cd /home/github/actions-runner
|
|||||||
|
|
||||||
You can read more about it in the official guide: [Configuring the self-hosted runner application as a service](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/configuring-the-self-hosted-runner-application-as-a-service).
|
You can read more about it in the official guide: [Configuring the self-hosted runner application as a service](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/configuring-the-self-hosted-runner-application-as-a-service).
|
||||||
|
|
||||||
|
### Configure GitHub Environments
|
||||||
|
|
||||||
|
The deployment workflows use [GitHub Environments](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) for `staging` and `production`. This enables environment-specific secrets, deployment protection rules (e.g. required reviewers, wait timers), and deployment status tracking.
|
||||||
|
|
||||||
|
To configure them, go to your repository's **Settings** > **Environments** and create the `staging` and `production` environments.
|
||||||
|
|
||||||
### Set Secrets
|
### Set Secrets
|
||||||
|
|
||||||
On your repository, configure secrets for the environment variables you need, the same ones described above, including `SECRET_KEY`, etc. Follow the [official GitHub guide for setting repository secrets](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-a-repository).
|
For each GitHub Environment (`staging` and `production`), configure the required secrets as [environment secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-an-environment). Environment secrets are preferred over [repository secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-a-repository) because they are scoped to the specific environment, reducing exposure and aligning with any protection rules you configure.
|
||||||
|
|
||||||
The current Github Actions workflows expect these secrets:
|
The deployment workflows require these secrets:
|
||||||
|
|
||||||
* `DOMAIN_PRODUCTION`
|
* `DOMAIN`
|
||||||
* `DOMAIN_STAGING`
|
|
||||||
* `STACK_NAME_PRODUCTION`
|
|
||||||
* `STACK_NAME_STAGING`
|
|
||||||
* `EMAILS_FROM_EMAIL`
|
|
||||||
* `FIRST_SUPERUSER`
|
* `FIRST_SUPERUSER`
|
||||||
* `FIRST_SUPERUSER_PASSWORD`
|
* `FIRST_SUPERUSER_PASSWORD`
|
||||||
* `POSTGRES_PASSWORD`
|
* `POSTGRES_PASSWORD`
|
||||||
* `SECRET_KEY`
|
* `SECRET_KEY`
|
||||||
* `LATEST_CHANGES`
|
|
||||||
* `SMOKESHOW_AUTH_KEY`
|
To enable emails, configure these additional secrets with the values from your email provider:
|
||||||
|
|
||||||
|
* `SMTP_HOST`
|
||||||
|
* `SMTP_USER`
|
||||||
|
* `SMTP_PASSWORD`
|
||||||
|
* `EMAILS_FROM_EMAIL`
|
||||||
|
|
||||||
|
To enable Sentry, configure the `SENTRY_DSN` secret.
|
||||||
|
|
||||||
## GitHub Action Deployment Workflows
|
## GitHub Action Deployment Workflows
|
||||||
|
|
||||||
@@ -278,32 +216,34 @@ There are GitHub Action workflows in the `.github/workflows` directory already c
|
|||||||
* `staging`: after pushing (or merging) to the branch `master`.
|
* `staging`: after pushing (or merging) to the branch `master`.
|
||||||
* `production`: after publishing a release.
|
* `production`: after publishing a release.
|
||||||
|
|
||||||
|
Both workflows are associated with their respective GitHub Environments, so deployments will be visible in the repository's **Environments** section and will respect any protection rules you configure.
|
||||||
|
|
||||||
|
### Prepare a Release
|
||||||
|
|
||||||
|
Install the [PR Submit GitHub App](https://github.com/apps/pr-submit) in your repository to enable the **Prepare Release** workflow.
|
||||||
|
|
||||||
|
Run the workflow manually from the **Actions** tab and select the version bump. It creates a pull request that updates the release notes. When you merge that pull request, the **Create Draft Release** workflow creates a draft GitHub release with the corresponding release notes.
|
||||||
|
|
||||||
|
Review and publish the draft release to trigger the production deployment.
|
||||||
|
|
||||||
If you need to add extra environments you could use those as a starting point.
|
If you need to add extra environments you could use those as a starting point.
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
Replace `fastapi-project.example.com` with your domain.
|
Replace `fastapi-project.example.com` with your domain.
|
||||||
|
|
||||||
### Main Traefik Dashboard
|
|
||||||
|
|
||||||
Traefik UI: `https://traefik.fastapi-project.example.com`
|
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
|
|
||||||
Frontend: `https://dashboard.fastapi-project.example.com`
|
Application (frontend and API): `https://fastapi-project.example.com`
|
||||||
|
|
||||||
Backend API docs: `https://api.fastapi-project.example.com/docs`
|
Interactive API docs: `https://fastapi-project.example.com/docs`
|
||||||
|
|
||||||
Backend API base URL: `https://api.fastapi-project.example.com`
|
|
||||||
|
|
||||||
Adminer: `https://adminer.fastapi-project.example.com`
|
Adminer: `https://adminer.fastapi-project.example.com`
|
||||||
|
|
||||||
### Staging
|
### Staging
|
||||||
|
|
||||||
Frontend: `https://dashboard.staging.fastapi-project.example.com`
|
Application (frontend and API): `https://staging.fastapi-project.example.com`
|
||||||
|
|
||||||
Backend API docs: `https://api.staging.fastapi-project.example.com/docs`
|
Interactive API docs: `https://staging.fastapi-project.example.com/docs`
|
||||||
|
|
||||||
Backend API base URL: `https://api.staging.fastapi-project.example.com`
|
|
||||||
|
|
||||||
Adminer: `https://adminer.staging.fastapi-project.example.com`
|
Adminer: `https://adminer.staging.fastapi-project.example.com`
|
||||||
|
|||||||
+85
-129
@@ -1,122 +1,95 @@
|
|||||||
# FastAPI Project - Development
|
# FastAPI Project - Development
|
||||||
|
|
||||||
## Docker Compose
|
## Local Development
|
||||||
|
|
||||||
* Start the local stack with Docker Compose:
|
For local development, run PostgreSQL and Mailcatcher with Docker Compose, and run the FastAPI and Vite development servers locally.
|
||||||
|
|
||||||
|
Start the supporting services:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker compose up -d db mailcatcher
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, from the `backend` directory, install the dependencies and prepare the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run bash scripts/prestart.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the FastAPI development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run fastapi dev
|
||||||
|
```
|
||||||
|
|
||||||
|
In another terminal, from the project root, install the frontend dependencies and start the Vite development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can open these URLs:
|
||||||
|
|
||||||
|
Frontend development server: <http://localhost:5173>
|
||||||
|
|
||||||
|
Backend API: <http://localhost:8000>
|
||||||
|
|
||||||
|
Automatic interactive API documentation with Swagger UI: <http://localhost:8000/docs>
|
||||||
|
|
||||||
|
Mailcatcher: <http://localhost:1080>
|
||||||
|
|
||||||
|
The frontend development server uses the backend at `http://localhost:8000`, as configured in `frontend/.env`.
|
||||||
|
|
||||||
|
### Frontend Served by FastAPI
|
||||||
|
|
||||||
|
Build the frontend from the `frontend` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The build is written to `backend/app/frontend` and served by FastAPI at <http://localhost:8000>. Rebuild the frontend after making frontend changes.
|
||||||
|
|
||||||
|
## Full Stack with Docker Compose
|
||||||
|
|
||||||
|
To run the backend and built frontend in Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend bash scripts/prestart.sh
|
||||||
docker compose watch
|
docker compose watch
|
||||||
```
|
```
|
||||||
|
|
||||||
* Now you can open your browser and interact with these URLs:
|
Now you can open these URLs:
|
||||||
|
|
||||||
Frontend, built with Docker, with routes handled based on the path: <http://localhost:5173>
|
Application, with the frontend and API served by FastAPI: <http://localhost:8000>
|
||||||
|
|
||||||
Backend, JSON based web API based on OpenAPI: <http://localhost:8000>
|
Automatic interactive API documentation with Swagger UI: <http://localhost:8000/docs>
|
||||||
|
|
||||||
Automatic interactive documentation with Swagger UI (from the OpenAPI backend): <http://localhost:8000/docs>
|
|
||||||
|
|
||||||
Adminer, database web administration: <http://localhost:8080>
|
Adminer, database web administration: <http://localhost:8080>
|
||||||
|
|
||||||
Traefik UI, to see how the routes are being handled by the proxy: <http://localhost:8090>
|
Traefik UI, to see how the routes are being handled by the proxy: <http://localhost:8090>
|
||||||
|
|
||||||
**Note**: The first time you start your stack, it might take a minute for it to be ready. While the backend waits for the database to be ready and configures everything. You can check the logs to monitor it.
|
Mailcatcher: <http://localhost:1080>
|
||||||
|
|
||||||
To check the logs, run (in another terminal):
|
Stop a locally running FastAPI server before starting the Compose backend because both use port `8000`.
|
||||||
|
|
||||||
```bash
|
**Note**: The first time you start the stack, it might take a minute for all the services to be ready. To monitor it, use `docker compose logs`, or `docker compose logs backend` for the backend service.
|
||||||
docker compose logs
|
|
||||||
```
|
|
||||||
|
|
||||||
To check the logs of a specific service, add the name of the service, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs backend
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mailcatcher
|
## Mailcatcher
|
||||||
|
|
||||||
Mailcatcher is a simple SMTP server that catches all emails sent by the backend during local development. Instead of sending real emails, they are captured and displayed in a web interface.
|
Mailcatcher captures emails sent during local development instead of delivering them. The local backend connects to it at `localhost:1025`, and the Compose backend connects to the `mailcatcher` service. Captured emails are available at <http://localhost:1080>.
|
||||||
|
|
||||||
This is useful for:
|
|
||||||
|
|
||||||
* Testing email functionality during development
|
|
||||||
* Verifying email content and formatting
|
|
||||||
* Debugging email-related functionality without sending real emails
|
|
||||||
|
|
||||||
The backend is automatically configured to use Mailcatcher when running with Docker Compose locally (SMTP on port 1025). All captured emails can be viewed at <http://localhost:1080>.
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
The Docker Compose files are configured so that each of the services is available in a different port in `localhost`.
|
|
||||||
|
|
||||||
For the backend and frontend, they use the same port that would be used by their local development server, so, the backend is at `http://localhost:8000` and the frontend at `http://localhost:5173`.
|
|
||||||
|
|
||||||
This way, you could turn off a Docker Compose service and start its local development service, and everything would keep working, because it all uses the same ports.
|
|
||||||
|
|
||||||
For example, you can stop that `frontend` service in the Docker Compose, in another terminal, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
And then start the local frontend development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Or you could stop the `backend` Docker Compose service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop backend
|
|
||||||
```
|
|
||||||
|
|
||||||
And then you can run the local development server for the backend:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
fastapi dev app/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Compose in `localhost.tiangolo.com`
|
|
||||||
|
|
||||||
When you start the Docker Compose stack, it uses `localhost` by default, with different ports for each service (backend, frontend, adminer, etc).
|
|
||||||
|
|
||||||
When you deploy it to production (or staging), it will deploy each service in a different subdomain, like `api.example.com` for the backend and `dashboard.example.com` for the frontend.
|
|
||||||
|
|
||||||
In the guide about [deployment](deployment.md) you can read about Traefik, the configured proxy. That's the component in charge of transmitting traffic to each service based on the subdomain.
|
|
||||||
|
|
||||||
If you want to test that it's all working locally, you can edit the local `.env` file, and change:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DOMAIN=localhost.tiangolo.com
|
|
||||||
```
|
|
||||||
|
|
||||||
That will be used by the Docker Compose files to configure the base domain for the services.
|
|
||||||
|
|
||||||
Traefik will use this to transmit traffic at `api.localhost.tiangolo.com` to the backend, and traffic at `dashboard.localhost.tiangolo.com` to the frontend.
|
|
||||||
|
|
||||||
The domain `localhost.tiangolo.com` is a special domain that is configured (with all its subdomains) to point to `127.0.0.1`. This way you can use that for your local development.
|
|
||||||
|
|
||||||
After you update it, run again:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose watch
|
|
||||||
```
|
|
||||||
|
|
||||||
When deploying, for example in production, the main Traefik is configured outside of the Docker Compose files. For local development, there's an included Traefik in `docker-compose.override.yml`, just to let you test that the domains work as expected, for example with `api.localhost.tiangolo.com` and `dashboard.localhost.tiangolo.com`.
|
|
||||||
|
|
||||||
## Docker Compose files and env vars
|
## Docker Compose files and env vars
|
||||||
|
|
||||||
There is a main `docker-compose.yml` file with all the configurations that apply to the whole stack, it is used automatically by `docker compose`.
|
There is a main `compose.yml` file with all the configurations that apply to the whole stack, it is used automatically by `docker compose`.
|
||||||
|
|
||||||
And there's also a `docker-compose.override.yml` with overrides for development, for example to mount the source code as a volume. It is used automatically by `docker compose` to apply overrides on top of `docker-compose.yml`.
|
And there's also a `compose.override.yml` with overrides for development, for example to mount the source code as a volume. It is used automatically by `docker compose` to apply overrides on top of `compose.yml`.
|
||||||
|
|
||||||
These Docker Compose files use the `.env` file containing configurations to be injected as environment variables in the containers.
|
The `compose.deploy.yml` file contains the deployment-specific settings, including HTTPS and automatic certificate handling. It is explicitly combined with `compose.yml` when deploying the application.
|
||||||
|
|
||||||
They also use some additional configurations taken from environment variables set in the scripts before calling the `docker compose` command.
|
The backend reads local settings from the `.env` file. Docker Compose also uses it for variable interpolation and passes the settings each container needs.
|
||||||
|
|
||||||
After changing variables, make sure you restart the stack:
|
After changing variables, make sure you restart the stack:
|
||||||
|
|
||||||
@@ -126,56 +99,59 @@ docker compose watch
|
|||||||
|
|
||||||
## The .env file
|
## The .env file
|
||||||
|
|
||||||
The `.env` file is the one that contains all your configurations, generated keys and passwords, etc.
|
The `.env` file contains the shared local defaults, generated keys, passwords, and other configuration. Its hostnames use `localhost` for processes running on your machine. Docker Compose overrides hostnames such as the database and SMTP server with their Compose service names.
|
||||||
|
|
||||||
Depending on your workflow, you could want to exclude it from Git, for example if your project is public. In that case, you would have to make sure to set up a way for your CI tools to obtain it while building or deploying your project.
|
Depending on your workflow, you could want to exclude it from Git, for example if your project is public. In that case, you would have to make sure to set up a way for your CI tools to obtain it while building or deploying your project.
|
||||||
|
|
||||||
One way to do it could be to add each environment variable to your CI/CD system, and updating the `docker-compose.yml` file to read that specific env var instead of reading the `.env` file.
|
One way to do it could be to add each environment variable to your CI/CD system.
|
||||||
|
|
||||||
## Pre-commits and code linting
|
## Pre-commits and code linting
|
||||||
|
|
||||||
we are using a tool called [pre-commit](https://pre-commit.com/) for code linting and formatting.
|
we are using a tool called [prek](https://prek.j178.dev/) (modern alternative to [Pre-commit](https://pre-commit.com/)) for code linting and formatting.
|
||||||
|
|
||||||
When you install it, it runs right before making a commit in git. This way it ensures that the code is consistent and formatted even before it is committed.
|
When you install it, it runs right before making a commit in git. This way it ensures that the code is consistent and formatted even before it is committed.
|
||||||
|
|
||||||
You can find a file `.pre-commit-config.yaml` with configurations at the root of the project.
|
You can find a file `.pre-commit-config.yaml` with configurations at the root of the project.
|
||||||
|
|
||||||
#### Install pre-commit to run automatically
|
#### Install prek to run automatically
|
||||||
|
|
||||||
`pre-commit` is already part of the dependencies of the project, but you could also install it globally if you prefer to, following [the official pre-commit docs](https://pre-commit.com/).
|
`prek` is already part of the dependencies of the project.
|
||||||
|
|
||||||
After having the `pre-commit` tool installed and available, you need to "install" it in the local repository, so that it runs automatically before each commit.
|
After having the `prek` tool installed and available, you need to "install" it in the local repository, so that it runs automatically before each commit.
|
||||||
|
|
||||||
Using `uv`, you could do it with:
|
Using `uv`, you could do it with (make sure you are inside `backend` folder):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
❯ uv run pre-commit install
|
❯ uv run prek install -f
|
||||||
pre-commit installed at .git/hooks/pre-commit
|
prek installed at `../.git/hooks/pre-commit`
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `-f` flag forces the installation, in case there was already a `pre-commit` hook previously installed.
|
||||||
|
|
||||||
Now whenever you try to commit, e.g. with:
|
Now whenever you try to commit, e.g. with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit
|
git commit
|
||||||
```
|
```
|
||||||
|
|
||||||
...pre-commit will run and check and format the code you are about to commit, and will ask you to add that code (stage it) with git again before committing.
|
...prek will run and check and format the code you are about to commit, and will ask you to add that code (stage it) with git again before committing.
|
||||||
|
|
||||||
Then you can `git add` the modified/fixed files again and now you can commit.
|
Then you can `git add` the modified/fixed files again and now you can commit.
|
||||||
|
|
||||||
#### Running pre-commit hooks manually
|
#### Running prek hooks manually
|
||||||
|
|
||||||
you can also run `pre-commit` manually on all the files, you can do it using `uv` with:
|
you can also run `prek` manually on all the files, you can do it using `uv` with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
❯ uv run pre-commit run --all-files
|
❯ uv run prek run --all-files
|
||||||
check for added large files..............................................Passed
|
check for added large files..............................................Passed
|
||||||
check toml...............................................................Passed
|
check toml...............................................................Passed
|
||||||
check yaml...............................................................Passed
|
check yaml...............................................................Passed
|
||||||
|
fix end of files.........................................................Passed
|
||||||
|
trim trailing whitespace.................................................Passed
|
||||||
ruff.....................................................................Passed
|
ruff.....................................................................Passed
|
||||||
ruff-format..............................................................Passed
|
ruff-format..............................................................Passed
|
||||||
eslint...................................................................Passed
|
biome check..............................................................Passed
|
||||||
prettier.................................................................Passed
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
@@ -186,9 +162,7 @@ The production or staging URLs would use these same paths, but with your own dom
|
|||||||
|
|
||||||
Development URLs, for local development.
|
Development URLs, for local development.
|
||||||
|
|
||||||
Frontend: <http://localhost:5173>
|
Application: <http://localhost:8000>
|
||||||
|
|
||||||
Backend: <http://localhost:8000>
|
|
||||||
|
|
||||||
Automatic Interactive Docs (Swagger UI): <http://localhost:8000/docs>
|
Automatic Interactive Docs (Swagger UI): <http://localhost:8000/docs>
|
||||||
|
|
||||||
@@ -199,21 +173,3 @@ Adminer: <http://localhost:8080>
|
|||||||
Traefik UI: <http://localhost:8090>
|
Traefik UI: <http://localhost:8090>
|
||||||
|
|
||||||
MailCatcher: <http://localhost:1080>
|
MailCatcher: <http://localhost:1080>
|
||||||
|
|
||||||
### Development URLs with `localhost.tiangolo.com` Configured
|
|
||||||
|
|
||||||
Development URLs, for local development.
|
|
||||||
|
|
||||||
Frontend: <http://dashboard.localhost.tiangolo.com>
|
|
||||||
|
|
||||||
Backend: <http://api.localhost.tiangolo.com>
|
|
||||||
|
|
||||||
Automatic Interactive Docs (Swagger UI): <http://api.localhost.tiangolo.com/docs>
|
|
||||||
|
|
||||||
Automatic Alternative Docs (ReDoc): <http://api.localhost.tiangolo.com/redoc>
|
|
||||||
|
|
||||||
Adminer: <http://localhost.tiangolo.com:8080>
|
|
||||||
|
|
||||||
Traefik UI: <http://localhost.tiangolo.com:8090>
|
|
||||||
|
|
||||||
MailCatcher: <http://localhost.tiangolo.com:1080>
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
services:
|
|
||||||
|
|
||||||
# Local services are available on their ports, but also available on:
|
|
||||||
# http://api.localhost.tiangolo.com: backend
|
|
||||||
# http://dashboard.localhost.tiangolo.com: frontend
|
|
||||||
# etc. To enable it, update .env, set:
|
|
||||||
# DOMAIN=localhost.tiangolo.com
|
|
||||||
proxy:
|
|
||||||
image: traefik:3.0
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "8090:8080"
|
|
||||||
# Duplicate the command from docker-compose.yml to add --api.insecure=true
|
|
||||||
command:
|
|
||||||
# Enable Docker in Traefik, so that it reads labels from Docker services
|
|
||||||
- --providers.docker
|
|
||||||
# Add a constraint to only use services with the label for this stack
|
|
||||||
- --providers.docker.constraints=Label(`traefik.constraint-label`, `traefik-public`)
|
|
||||||
# Do not expose all Docker services, only the ones explicitly exposed
|
|
||||||
- --providers.docker.exposedbydefault=false
|
|
||||||
# Create an entrypoint "http" listening on port 80
|
|
||||||
- --entrypoints.http.address=:80
|
|
||||||
# Create an entrypoint "https" listening on port 443
|
|
||||||
- --entrypoints.https.address=:443
|
|
||||||
# Enable the access log, with HTTP requests
|
|
||||||
- --accesslog
|
|
||||||
# Enable the Traefik log, for configurations and errors
|
|
||||||
- --log
|
|
||||||
# Enable debug logging for local development
|
|
||||||
- --log.level=DEBUG
|
|
||||||
# Enable the Dashboard and API
|
|
||||||
- --api
|
|
||||||
# Enable the Dashboard and API in insecure mode for local development
|
|
||||||
- --api.insecure=true
|
|
||||||
labels:
|
|
||||||
# Enable Traefik for this service, to make it available in the public network
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.constraint-label=traefik-public
|
|
||||||
# Dummy https-redirect middleware that doesn't really redirect, only to
|
|
||||||
# allow running it locally
|
|
||||||
- traefik.http.middlewares.https-redirect.contenttype.autodetect=false
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
|
|
||||||
db:
|
|
||||||
restart: "no"
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
|
|
||||||
adminer:
|
|
||||||
restart: "no"
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
|
|
||||||
backend:
|
|
||||||
restart: "no"
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
# command: sleep infinity # Infinite loop to keep container alive doing nothing
|
|
||||||
command:
|
|
||||||
- fastapi
|
|
||||||
- run
|
|
||||||
- --reload
|
|
||||||
- "app/main.py"
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- path: ./backend
|
|
||||||
action: sync
|
|
||||||
target: /app
|
|
||||||
ignore:
|
|
||||||
- ./backend/.venv
|
|
||||||
- .venv
|
|
||||||
- path: ./backend/pyproject.toml
|
|
||||||
action: rebuild
|
|
||||||
# TODO: remove once coverage is done locally
|
|
||||||
volumes:
|
|
||||||
- ./backend/htmlcov:/app/htmlcov
|
|
||||||
environment:
|
|
||||||
SMTP_HOST: "mailcatcher"
|
|
||||||
SMTP_PORT: "1025"
|
|
||||||
SMTP_TLS: "false"
|
|
||||||
EMAILS_FROM_EMAIL: "noreply@example.com"
|
|
||||||
|
|
||||||
mailcatcher:
|
|
||||||
image: schickling/mailcatcher
|
|
||||||
ports:
|
|
||||||
- "1080:1080"
|
|
||||||
- "1025:1025"
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
restart: "no"
|
|
||||||
ports:
|
|
||||||
- "5173:80"
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
args:
|
|
||||||
- VITE_API_URL=http://localhost:8000
|
|
||||||
- NODE_ENV=development
|
|
||||||
|
|
||||||
playwright:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile.playwright
|
|
||||||
args:
|
|
||||||
- VITE_API_URL=http://backend:8000
|
|
||||||
- NODE_ENV=production
|
|
||||||
ipc: host
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
- mailcatcher
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- VITE_API_URL=http://backend:8000
|
|
||||||
- MAILCATCHER_HOST=http://mailcatcher:1080
|
|
||||||
# For the reports when run locally
|
|
||||||
- PLAYWRIGHT_HTML_HOST=0.0.0.0
|
|
||||||
- CI=${CI}
|
|
||||||
volumes:
|
|
||||||
- ./frontend/blob-report:/app/blob-report
|
|
||||||
- ./frontend/test-results:/app/test-results
|
|
||||||
ports:
|
|
||||||
- 9323:9323
|
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik-public:
|
|
||||||
# For local dev, don't expect an external Traefik network
|
|
||||||
external: false
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
services:
|
|
||||||
traefik:
|
|
||||||
image: traefik:3.0
|
|
||||||
ports:
|
|
||||||
# Listen on port 80, default for HTTP, necessary to redirect to HTTPS
|
|
||||||
- 80:80
|
|
||||||
# Listen on port 443, default for HTTPS
|
|
||||||
- 443:443
|
|
||||||
restart: always
|
|
||||||
labels:
|
|
||||||
# Enable Traefik for this service, to make it available in the public network
|
|
||||||
- traefik.enable=true
|
|
||||||
# Use the traefik-public network (declared below)
|
|
||||||
- traefik.docker.network=traefik-public
|
|
||||||
# Define the port inside of the Docker service to use
|
|
||||||
- traefik.http.services.traefik-dashboard.loadbalancer.server.port=8080
|
|
||||||
# Make Traefik use this domain (from an environment variable) in HTTP
|
|
||||||
- traefik.http.routers.traefik-dashboard-http.entrypoints=http
|
|
||||||
- traefik.http.routers.traefik-dashboard-http.rule=Host(`traefik.${DOMAIN?Variable not set}`)
|
|
||||||
# traefik-https the actual router using HTTPS
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.entrypoints=https
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.rule=Host(`traefik.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.tls=true
|
|
||||||
# Use the "le" (Let's Encrypt) resolver created below
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.tls.certresolver=le
|
|
||||||
# Use the special Traefik service api@internal with the web UI/Dashboard
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.service=api@internal
|
|
||||||
# https-redirect middleware to redirect HTTP to HTTPS
|
|
||||||
- traefik.http.middlewares.https-redirect.redirectscheme.scheme=https
|
|
||||||
- traefik.http.middlewares.https-redirect.redirectscheme.permanent=true
|
|
||||||
# traefik-http set up only to use the middleware to redirect to https
|
|
||||||
- traefik.http.routers.traefik-dashboard-http.middlewares=https-redirect
|
|
||||||
# admin-auth middleware with HTTP Basic auth
|
|
||||||
# Using the environment variables USERNAME and HASHED_PASSWORD
|
|
||||||
- traefik.http.middlewares.admin-auth.basicauth.users=${USERNAME?Variable not set}:${HASHED_PASSWORD?Variable not set}
|
|
||||||
# Enable HTTP Basic auth, using the middleware created above
|
|
||||||
- traefik.http.routers.traefik-dashboard-https.middlewares=admin-auth
|
|
||||||
volumes:
|
|
||||||
# Add Docker as a mounted volume, so that Traefik can read the labels of other services
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
# Mount the volume to store the certificates
|
|
||||||
- traefik-public-certificates:/certificates
|
|
||||||
command:
|
|
||||||
# Enable Docker in Traefik, so that it reads labels from Docker services
|
|
||||||
- --providers.docker
|
|
||||||
# Do not expose all Docker services, only the ones explicitly exposed
|
|
||||||
- --providers.docker.exposedbydefault=false
|
|
||||||
# Create an entrypoint "http" listening on port 80
|
|
||||||
- --entrypoints.http.address=:80
|
|
||||||
# Create an entrypoint "https" listening on port 443
|
|
||||||
- --entrypoints.https.address=:443
|
|
||||||
# Create the certificate resolver "le" for Let's Encrypt, uses the environment variable EMAIL
|
|
||||||
- --certificatesresolvers.le.acme.email=${EMAIL?Variable not set}
|
|
||||||
# Store the Let's Encrypt certificates in the mounted volume
|
|
||||||
- --certificatesresolvers.le.acme.storage=/certificates/acme.json
|
|
||||||
# Use the TLS Challenge for Let's Encrypt
|
|
||||||
- --certificatesresolvers.le.acme.tlschallenge=true
|
|
||||||
# Enable the access log, with HTTP requests
|
|
||||||
- --accesslog
|
|
||||||
# Enable the Traefik log, for configurations and errors
|
|
||||||
- --log
|
|
||||||
# Enable the Dashboard and API
|
|
||||||
- --api
|
|
||||||
networks:
|
|
||||||
# Use the public network created to be shared between Traefik and
|
|
||||||
# any other service that needs to be publicly available with HTTPS
|
|
||||||
- traefik-public
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
# Create a volume to store the certificates, even if the container is recreated
|
|
||||||
traefik-public-certificates:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
# Use the previously created public network "traefik-public", shared with other
|
|
||||||
# services that need to be publicly available via this Traefik
|
|
||||||
traefik-public:
|
|
||||||
external: true
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
services:
|
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:17
|
|
||||||
restart: always
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
|
||||||
interval: 10s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
timeout: 10s
|
|
||||||
volumes:
|
|
||||||
- app-db-data:/var/lib/postgresql/data/pgdata
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB?Variable not set}
|
|
||||||
|
|
||||||
adminer:
|
|
||||||
image: adminer
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
environment:
|
|
||||||
- ADMINER_DESIGN=pepa-linha-dark
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.docker.network=traefik-public
|
|
||||||
- traefik.constraint-label=traefik-public
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.rule=Host(`adminer.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.entrypoints=http
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-http.middlewares=https-redirect
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.rule=Host(`adminer.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.entrypoints=https
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls=true
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls.certresolver=le
|
|
||||||
- traefik.http.services.${STACK_NAME?Variable not set}-adminer.loadbalancer.server.port=8080
|
|
||||||
|
|
||||||
prestart:
|
|
||||||
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
command: bash scripts/prestart.sh
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- DOMAIN=${DOMAIN}
|
|
||||||
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
|
|
||||||
- ENVIRONMENT=${ENVIRONMENT}
|
|
||||||
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
|
|
||||||
- SECRET_KEY=${SECRET_KEY?Variable not set}
|
|
||||||
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
|
|
||||||
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
|
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
|
||||||
- SMTP_USER=${SMTP_USER}
|
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
|
||||||
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
|
|
||||||
- POSTGRES_SERVER=db
|
|
||||||
- POSTGRES_PORT=${POSTGRES_PORT}
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB}
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
|
||||||
- SENTRY_DSN=${SENTRY_DSN}
|
|
||||||
|
|
||||||
backend:
|
|
||||||
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
prestart:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- DOMAIN=${DOMAIN}
|
|
||||||
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
|
|
||||||
- ENVIRONMENT=${ENVIRONMENT}
|
|
||||||
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
|
|
||||||
- SECRET_KEY=${SECRET_KEY?Variable not set}
|
|
||||||
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
|
|
||||||
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
|
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
|
||||||
- SMTP_USER=${SMTP_USER}
|
|
||||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
|
||||||
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
|
|
||||||
- POSTGRES_SERVER=db
|
|
||||||
- POSTGRES_PORT=${POSTGRES_PORT}
|
|
||||||
- POSTGRES_DB=${POSTGRES_DB}
|
|
||||||
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
|
||||||
- SENTRY_DSN=${SENTRY_DSN}
|
|
||||||
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.docker.network=traefik-public
|
|
||||||
- traefik.constraint-label=traefik-public
|
|
||||||
|
|
||||||
- traefik.http.services.${STACK_NAME?Variable not set}-backend.loadbalancer.server.port=8000
|
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.rule=Host(`api.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.entrypoints=http
|
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.rule=Host(`api.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.entrypoints=https
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls=true
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-https.tls.certresolver=le
|
|
||||||
|
|
||||||
# Enable redirection for HTTP and HTTPS
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}'
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
args:
|
|
||||||
- VITE_API_URL=https://api.${DOMAIN?Variable not set}
|
|
||||||
- NODE_ENV=production
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.docker.network=traefik-public
|
|
||||||
- traefik.constraint-label=traefik-public
|
|
||||||
|
|
||||||
- traefik.http.services.${STACK_NAME?Variable not set}-frontend.loadbalancer.server.port=80
|
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.rule=Host(`dashboard.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.entrypoints=http
|
|
||||||
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.rule=Host(`dashboard.${DOMAIN?Variable not set}`)
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.entrypoints=https
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls=true
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-https.tls.certresolver=le
|
|
||||||
|
|
||||||
# Enable redirection for HTTP and HTTPS
|
|
||||||
- traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect
|
|
||||||
volumes:
|
|
||||||
app-db-data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik-public:
|
|
||||||
# Allow setting it to false for testing
|
|
||||||
external: true
|
|
||||||
+1
-1
@@ -27,4 +27,4 @@ openapi.json
|
|||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/playwright/.auth/
|
/playwright/.auth/
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
24
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Stage 0, "build-stage", based on Node.js, to build and compile the frontend
|
|
||||||
FROM node:24 AS build-stage
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package*.json /app/
|
|
||||||
|
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
COPY ./ /app/
|
|
||||||
|
|
||||||
ARG VITE_API_URL=${VITE_API_URL}
|
|
||||||
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
|
|
||||||
# Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx
|
|
||||||
FROM nginx:1
|
|
||||||
|
|
||||||
COPY --from=build-stage /app/dist/ /usr/share/nginx/html
|
|
||||||
|
|
||||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY ./nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf
|
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
FROM mcr.microsoft.com/playwright:v1.57.0-noble
|
FROM mcr.microsoft.com/playwright:v1.61.1-noble
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json /app/
|
RUN apt-get update && apt-get install -y unzip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN npm install
|
RUN curl -fsSL https://bun.sh/install | bash
|
||||||
|
ENV PATH="/root/.bun/bin:$PATH"
|
||||||
|
|
||||||
COPY ./ /app/
|
COPY package.json bun.lock /app/
|
||||||
|
|
||||||
ARG VITE_API_URL=${VITE_API_URL}
|
COPY frontend/package.json /app/frontend/
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
|
RUN bun install
|
||||||
|
|
||||||
|
COPY ./frontend /app/frontend
|
||||||
|
|||||||
+21
-58
@@ -2,52 +2,22 @@
|
|||||||
|
|
||||||
The frontend is built with [Vite](https://vitejs.dev/), [React](https://reactjs.org/), [TypeScript](https://www.typescriptlang.org/), [TanStack Query](https://tanstack.com/query), [TanStack Router](https://tanstack.com/router) and [Tailwind CSS](https://tailwindcss.com/).
|
The frontend is built with [Vite](https://vitejs.dev/), [React](https://reactjs.org/), [TypeScript](https://www.typescriptlang.org/), [TanStack Query](https://tanstack.com/query), [TanStack Router](https://tanstack.com/router) and [Tailwind CSS](https://tailwindcss.com/).
|
||||||
|
|
||||||
## Frontend development
|
## Requirements
|
||||||
|
|
||||||
Before you begin, ensure that you have either the Node Version Manager (nvm) or Fast Node Manager (fnm) installed on your system.
|
- [Bun](https://bun.sh/) (recommended) or [Node.js](https://nodejs.org/)
|
||||||
|
|
||||||
* To install fnm follow the [official fnm guide](https://github.com/Schniz/fnm#installation). If you prefer nvm, you can install it using the [official nvm guide](https://github.com/nvm-sh/nvm#installing-and-updating).
|
## Quick Start
|
||||||
|
|
||||||
* After installing either nvm or fnm, proceed to the `frontend` directory:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
bun install
|
||||||
```
|
bun run dev
|
||||||
* If the Node.js version specified in the `.nvmrc` file isn't installed on your system, you can install it using the appropriate command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# If using fnm
|
|
||||||
fnm install
|
|
||||||
|
|
||||||
# If using nvm
|
|
||||||
nvm install
|
|
||||||
```
|
|
||||||
|
|
||||||
* Once the installation is complete, switch to the installed version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# If using fnm
|
|
||||||
fnm use
|
|
||||||
|
|
||||||
# If using nvm
|
|
||||||
nvm use
|
|
||||||
```
|
|
||||||
|
|
||||||
* Within the `frontend` directory, install the necessary NPM packages:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
* And start the live server with the following `npm` script:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
* Then open your browser at http://localhost:5173/.
|
* Then open your browser at http://localhost:5173/.
|
||||||
|
|
||||||
Notice that this live server is not running inside Docker, it's for local development, and that is the recommended workflow. Once you are happy with your frontend, you can build the frontend Docker image and start it, to test it in a production-like environment. But building the image at every change will not be as productive as running the local development server with live reload.
|
Run `uv run bash scripts/prestart.sh` and `uv run fastapi dev` from the `backend` directory, with PostgreSQL running in Docker Compose. See [../development.md](../development.md) for the complete setup.
|
||||||
|
|
||||||
|
To serve the frontend with FastAPI, run `bun run build` from the `frontend` directory and open `http://localhost:8000`.
|
||||||
|
|
||||||
Check the file `package.json` to see other available options.
|
Check the file `package.json` to see other available options.
|
||||||
|
|
||||||
@@ -57,44 +27,36 @@ If you are developing an API-only app and want to remove the frontend, you can d
|
|||||||
|
|
||||||
* Remove the `./frontend` directory.
|
* Remove the `./frontend` directory.
|
||||||
|
|
||||||
* In the `docker-compose.yml` file, remove the whole service / section `frontend`.
|
* In the `backend/app/main.py` file, remove the `app.frontend()` call.
|
||||||
|
|
||||||
* In the `docker-compose.override.yml` file, remove the whole service / section `frontend` and `playwright`.
|
* In the `backend/Dockerfile` file, remove the frontend build stage and the `COPY --from=frontend-build` instruction.
|
||||||
|
|
||||||
|
* In the `compose.override.yml` file, remove the `playwright` service.
|
||||||
|
|
||||||
Done, you have a frontend-less (api-only) app. 🤓
|
Done, you have a frontend-less (api-only) app. 🤓
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
If you want, you can also remove the `FRONTEND` environment variables from:
|
|
||||||
|
|
||||||
* `.env`
|
|
||||||
* `./scripts/*.sh`
|
|
||||||
|
|
||||||
But it would be only to clean them up, leaving them won't really have any effect either way.
|
|
||||||
|
|
||||||
## Generate Client
|
## Generate Client
|
||||||
|
|
||||||
### Automatically
|
### Automatically
|
||||||
|
|
||||||
* Activate the backend virtual environment.
|
|
||||||
* From the top level project directory, run the script:
|
* From the top level project directory, run the script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/generate-client.sh
|
bash ./scripts/generate-client.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
* Commit the changes.
|
* Commit the changes.
|
||||||
|
|
||||||
### Manually
|
### Manually
|
||||||
|
|
||||||
* Start the Docker Compose stack.
|
* Make sure the backend is running.
|
||||||
|
|
||||||
* Download the OpenAPI JSON file from `http://localhost/api/v1/openapi.json` and copy it to a new file `openapi.json` at the root of the `frontend` directory.
|
* Download the OpenAPI JSON file from `http://localhost:8000/api/v1/openapi.json` and copy it to a new file `openapi.json` at the root of the `frontend` directory.
|
||||||
|
|
||||||
* To generate the frontend client, run:
|
* To generate the frontend client, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run generate-client
|
bun run generate-client
|
||||||
```
|
```
|
||||||
|
|
||||||
* Commit the changes.
|
* Commit the changes.
|
||||||
@@ -103,10 +65,10 @@ Notice that everytime the backend changes (changing the OpenAPI schema), you sho
|
|||||||
|
|
||||||
## Using a Remote API
|
## Using a Remote API
|
||||||
|
|
||||||
If you want to use a remote API, you can set the environment variable `VITE_API_URL` to the URL of the remote API. For example, you can set it in the `frontend/.env` file:
|
By default, the built frontend uses the same origin as the FastAPI app. If you want to use a remote API while running the Vite development server, you can set the environment variable `VITE_API_URL` to the URL of the remote API. For example, you can set it in the `frontend/.env` file:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
VITE_API_URL=https://api.my-domain.example.com
|
VITE_API_URL=https://my-domain.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, when you run the frontend, it will use that URL as the base URL for the API.
|
Then, when you run the frontend, it will use that URL as the base URL for the API.
|
||||||
@@ -127,19 +89,20 @@ The frontend code is structured as follows:
|
|||||||
The frontend includes initial end-to-end tests using Playwright. To run the tests, you need to have the Docker Compose stack running. Start the stack with the following command:
|
The frontend includes initial end-to-end tests using Playwright. To run the tests, you need to have the Docker Compose stack running. Start the stack with the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker compose run --rm backend bash scripts/prestart.sh
|
||||||
docker compose up -d --wait backend
|
docker compose up -d --wait backend
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, you can run the tests with the following command:
|
Then, you can run the tests with the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx playwright test
|
bunx playwright test
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also run your tests in UI mode to see the browser and interact with it running:
|
You can also run your tests in UI mode to see the browser and interact with it running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx playwright test --ui
|
bunx playwright test --ui
|
||||||
```
|
```
|
||||||
|
|
||||||
To stop and remove the Docker Compose stack and clean the data created in tests, use the following command:
|
To stop and remove the Docker Compose stack and clean the data created in tests, use the following command:
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
|
||||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
location /api {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
location /docs {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
location /redoc {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html index.htm;
|
|
||||||
try_files $uri /index.html =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
include /etc/nginx/extra-conf.d/*.conf;
|
|
||||||
}
|
|
||||||
@@ -5,29 +5,17 @@ export default defineConfig({
|
|||||||
output: "./src/client",
|
output: "./src/client",
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
"legacy/axios",
|
{ name: "@hey-api/client-axios", throwOnError: true },
|
||||||
|
{ name: "@hey-api/typescript", case: "preserve" },
|
||||||
{
|
{
|
||||||
name: "@hey-api/sdk",
|
name: "@hey-api/sdk",
|
||||||
// NOTE: this doesn't allow tree-shaking
|
operations: {
|
||||||
asClass: true,
|
// NOTE: this doesn't allow tree-shaking
|
||||||
operationId: true,
|
strategy: "byTags",
|
||||||
classNameBuilder: "{{name}}Service",
|
methods: "static",
|
||||||
methodNameBuilder: (operation) => {
|
containerName: "{{name}}Service",
|
||||||
// @ts-expect-error
|
methodName: (name: string): string => name.replace(/^[^-]*-/, ""),
|
||||||
let name: string = operation.name
|
|
||||||
// @ts-expect-error
|
|
||||||
const service: string = operation.service
|
|
||||||
|
|
||||||
if (service && name.toLowerCase().startsWith(service.toLowerCase())) {
|
|
||||||
name = name.slice(service.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
return name.charAt(0).toLowerCase() + name.slice(1)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "@hey-api/schemas",
|
|
||||||
type: "json",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user