Skip to content

Commit b9cbb4f

Browse files
authored
👷 Add continuous deployment and refactors needed for it (#667)
1 parent bb7da40 commit b9cbb4f

File tree

13 files changed

+340
-110
lines changed

13 files changed

+340
-110
lines changed

.env

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,32 @@
1-
# Update this with your app domain
1+
# Domain
2+
# This would be set to the production domain with an env var on deployment
23
DOMAIN=localhost
3-
# DOMAIN=localhost.tiangolo.com
44

5-
SERVER_HOST=http://localhost
5+
# Environment: local, staging, production
6+
ENVIRONMENT=local
67

78
PROJECT_NAME="FastAPI Project"
8-
99
STACK_NAME=fastapi-project
1010

1111
# Backend
1212
BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"
1313
SECRET_KEY=changethis
1414
FIRST_SUPERUSER=[email protected]
1515
FIRST_SUPERUSER_PASSWORD=changethis
16+
USERS_OPEN_REGISTRATION=False
17+
18+
# Emails
1619
SMTP_HOST=
1720
SMTP_USER=
1821
SMTP_PASSWORD=
1922
EMAILS_FROM_EMAIL=[email protected]
2023
SMTP_TLS=True
2124
SMTP_PORT=587
2225

23-
USERS_OPEN_REGISTRATION=False
24-
2526
# Postgres
26-
POSTGRES_SERVER=db
27-
POSTGRES_USER=postgres
27+
POSTGRES_SERVER=localhost
2828
POSTGRES_DB=app
29+
POSTGRES_USER=postgres
2930
POSTGRES_PASSWORD=changethis
3031

3132
# PgAdmin
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Deploy to Production
2+
3+
on:
4+
release:
5+
types:
6+
- published
7+
8+
jobs:
9+
deploy:
10+
runs-on:
11+
- self-hosted
12+
- production
13+
env:
14+
ENVIRONMENT: production
15+
DOMAIN: ${{ secrets.DOMAIN_PRODUCTION }}
16+
SECRET_KEY: ${{ secrets.SECRET_KEY }}
17+
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
18+
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
19+
SMTP_HOST: ${{ secrets.SMTP_HOST }}
20+
SMTP_USER: ${{ secrets.SMTP_USER }}
21+
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
22+
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
23+
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
24+
PGADMIN_DEFAULT_EMAIL: ${{ secrets.PGADMIN_DEFAULT_EMAIL }}
25+
PGADMIN_DEFAULT_PASSWORD: ${{ secrets.PGADMIN_DEFAULT_PASSWORD }}
26+
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
27+
FLOWER_BASIC_AUTH: ${{ secrets.FLOWER_BASIC_AUTH }}
28+
steps:
29+
- name: Checkout
30+
uses: actions/checkout@v4
31+
- run: docker compose -f docker-compose.yml build
32+
- run: docker compose -f docker-compose.yml up -d

.github/workflows/deploy-staging.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Deploy to Staging
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
8+
jobs:
9+
deploy:
10+
runs-on:
11+
- self-hosted
12+
- staging
13+
env:
14+
ENVIRONMENT: staging
15+
DOMAIN: ${{ secrets.DOMAIN_STAGING }}
16+
SECRET_KEY: ${{ secrets.SECRET_KEY }}
17+
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
18+
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
19+
SMTP_HOST: ${{ secrets.SMTP_HOST }}
20+
SMTP_USER: ${{ secrets.SMTP_USER }}
21+
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
22+
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
23+
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
24+
PGADMIN_DEFAULT_EMAIL: ${{ secrets.PGADMIN_DEFAULT_EMAIL }}
25+
PGADMIN_DEFAULT_PASSWORD: ${{ secrets.PGADMIN_DEFAULT_PASSWORD }}
26+
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
27+
FLOWER_BASIC_AUTH: ${{ secrets.FLOWER_BASIC_AUTH }}
28+
steps:
29+
- name: Checkout
30+
uses: actions/checkout@v4
31+
- run: docker compose -f docker-compose.yml build
32+
- run: docker compose -f docker-compose.yml up -d

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ But don't worry, you can just update any of that in the `.env` files afterwards.
146146

147147
The input variables, with their default values (some auto generated) are:
148148

149-
- `domain`: (default: `"localhost"`) Which domain name to use for the project, by default, localhost, but you should change it later (in .env).
150149
- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env).
151150
- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels (no spaces) (in .env).
152151
- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above.

backend/app/api/routes/users.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def create_user(*, session: SessionDep, user_in: UserCreate) -> Any:
6060
)
6161

6262
user = crud.create_user(session=session, user_create=user_in)
63-
if settings.EMAILS_ENABLED and user_in.email:
63+
if settings.emails_enabled and user_in.email:
6464
email_data = generate_new_account_email(
6565
email_to=user_in.email, username=user_in.email, password=user_in.password
6666
)

backend/app/core/config.py

Lines changed: 51 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,95 @@
11
import secrets
2-
from typing import Any
2+
from typing import Annotated, Any, Literal
33

44
from pydantic import (
5-
AnyHttpUrl,
5+
AnyUrl,
6+
BeforeValidator,
67
HttpUrl,
78
PostgresDsn,
8-
ValidationInfo,
9-
field_validator,
9+
computed_field,
10+
model_validator,
1011
)
12+
from pydantic_core import MultiHostUrl
1113
from pydantic_settings import BaseSettings, SettingsConfigDict
14+
from typing_extensions import Self
15+
16+
17+
def parse_cors(v: Any) -> list[str] | str:
18+
if isinstance(v, str) and not v.startswith("["):
19+
return [i.strip() for i in v.split(",")]
20+
elif isinstance(v, list | str):
21+
return v
22+
raise ValueError(v)
1223

1324

1425
class Settings(BaseSettings):
26+
model_config = SettingsConfigDict(env_file=".env", env_ignore_empty=True)
1527
API_V1_STR: str = "/api/v1"
1628
SECRET_KEY: str = secrets.token_urlsafe(32)
1729
# 60 minutes * 24 hours * 8 days = 8 days
1830
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
19-
SERVER_HOST: AnyHttpUrl
20-
BACKEND_CORS_ORIGINS: list[AnyHttpUrl] | str = []
21-
22-
@field_validator("BACKEND_CORS_ORIGINS", mode="before")
23-
@classmethod
24-
def assemble_cors_origins(cls, v: str | list[str]) -> list[str] | str:
25-
if isinstance(v, str) and not v.startswith("["):
26-
return [i.strip() for i in v.split(",")]
27-
elif isinstance(v, list | str):
28-
return v
29-
raise ValueError(v)
31+
DOMAIN: str = "localhost"
32+
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
3033

31-
PROJECT_NAME: str
32-
SENTRY_DSN: HttpUrl | None = None
34+
@computed_field # type: ignore[misc]
35+
@property
36+
def server_host(self) -> str:
37+
# Use HTTPS for anything other than local development
38+
if self.ENVIRONMENT == "local":
39+
return f"http://{self.DOMAIN}"
40+
return f"https://{self.DOMAIN}"
3341

34-
@field_validator("SENTRY_DSN", mode="before")
35-
@classmethod
36-
def sentry_dsn_can_be_blank(cls, v: str) -> str | None:
37-
if not v:
38-
return None
39-
return v
42+
BACKEND_CORS_ORIGINS: Annotated[
43+
list[AnyUrl] | str, BeforeValidator(parse_cors)
44+
] = []
4045

46+
PROJECT_NAME: str
47+
SENTRY_DSN: HttpUrl | None = None
4148
POSTGRES_SERVER: str
4249
POSTGRES_USER: str
4350
POSTGRES_PASSWORD: str
44-
POSTGRES_DB: str
45-
SQLALCHEMY_DATABASE_URI: PostgresDsn | None = None
46-
47-
@field_validator("SQLALCHEMY_DATABASE_URI", mode="before")
48-
def assemble_db_connection(cls, v: str | None, info: ValidationInfo) -> Any:
49-
if isinstance(v, str):
50-
return v
51-
return PostgresDsn.build(
51+
POSTGRES_DB: str = ""
52+
53+
@computed_field # type: ignore[misc]
54+
@property
55+
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
56+
return MultiHostUrl.build(
5257
scheme="postgresql+psycopg",
53-
username=info.data.get("POSTGRES_USER"),
54-
password=info.data.get("POSTGRES_PASSWORD"),
55-
host=info.data.get("POSTGRES_SERVER"),
56-
path=f"{info.data.get('POSTGRES_DB') or ''}",
58+
username=self.POSTGRES_USER,
59+
password=self.POSTGRES_PASSWORD,
60+
host=self.POSTGRES_SERVER,
61+
path=self.POSTGRES_DB,
5762
)
5863

5964
SMTP_TLS: bool = True
60-
SMTP_PORT: int | None = None
65+
SMTP_PORT: int = 587
6166
SMTP_HOST: str | None = None
6267
SMTP_USER: str | None = None
6368
SMTP_PASSWORD: str | None = None
6469
# TODO: update type to EmailStr when sqlmodel supports it
6570
EMAILS_FROM_EMAIL: str | None = None
6671
EMAILS_FROM_NAME: str | None = None
6772

68-
@field_validator("EMAILS_FROM_NAME")
69-
def get_project_name(cls, v: str | None, info: ValidationInfo) -> str:
70-
if not v:
71-
return str(info.data["PROJECT_NAME"])
72-
return v
73+
@model_validator(mode="after")
74+
def set_default_emails_from(self) -> Self:
75+
if not self.EMAILS_FROM_NAME:
76+
self.EMAILS_FROM_NAME = self.PROJECT_NAME
77+
return self
7378

7479
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
7580
EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build"
76-
EMAILS_ENABLED: bool = False
77-
78-
@field_validator("EMAILS_ENABLED", mode="before")
79-
def get_emails_enabled(cls, v: bool, info: ValidationInfo) -> bool:
80-
return bool(
81-
info.data.get("SMTP_HOST")
82-
and info.data.get("SMTP_PORT")
83-
and info.data.get("EMAILS_FROM_EMAIL")
84-
)
81+
82+
@computed_field # type: ignore[misc]
83+
@property
84+
def emails_enabled(self) -> bool:
85+
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
8586

8687
# TODO: update type to EmailStr when sqlmodel supports it
8788
EMAIL_TEST_USER: str = "[email protected]"
8889
# TODO: update type to EmailStr when sqlmodel supports it
8990
FIRST_SUPERUSER: str
9091
FIRST_SUPERUSER_PASSWORD: str
9192
USERS_OPEN_REGISTRATION: bool = False
92-
model_config = SettingsConfigDict(env_file=".env")
9393

9494

9595
settings = Settings() # type: ignore

backend/app/tests/api/api_v1/test_login.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ def test_recovery_password(
4242
client: TestClient, normal_user_token_headers: dict[str, str], mocker: MockerFixture
4343
) -> None:
4444
mocker.patch("app.utils.send_email", return_value=None)
45-
mocker.patch("app.core.config.settings.EMAILS_ENABLED", True)
45+
mocker.patch("app.core.config.settings.SMTP_HOST", "smtp.example.com")
46+
mocker.patch("app.core.config.settings.SMTP_USER", "[email protected]")
4647
4748
r = client.post(
4849
f"{settings.API_V1_STR}/password-recovery/{email}",

backend/app/tests/api/api_v1/test_users.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ def test_create_user_new_email(
3737
mocker: MockerFixture,
3838
) -> None:
3939
mocker.patch("app.utils.send_email")
40-
mocker.patch("app.core.config.settings.EMAILS_ENABLED", True)
40+
mocker.patch("app.core.config.settings.SMTP_HOST", "smtp.example.com")
41+
mocker.patch("app.core.config.settings.SMTP_USER", "[email protected]")
4142
username = random_email()
4243
password = random_lower_string()
4344
data = {"email": username, "password": password}

backend/app/utils.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class EmailData:
1717
subject: str
1818

1919

20-
def render_email_template(*, template_name: str, context: dict[str, Any]):
20+
def render_email_template(*, template_name: str, context: dict[str, Any]) -> str:
2121
template_str = (Path(settings.EMAIL_TEMPLATES_DIR) / template_name).read_text()
2222
html_content = Template(template_str).render(context)
2323
return html_content
@@ -29,7 +29,7 @@ def send_email(
2929
subject: str = "",
3030
html_content: str = "",
3131
) -> None:
32-
assert settings.EMAILS_ENABLED, "no provided configuration for email variables"
32+
assert settings.emails_enabled, "no provided configuration for email variables"
3333
message = emails.Message(
3434
subject=subject,
3535
html=html_content,
@@ -59,8 +59,7 @@ def generate_test_email(email_to: str) -> EmailData:
5959
def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData:
6060
project_name = settings.PROJECT_NAME
6161
subject = f"{project_name} - Password recovery for user {email}"
62-
server_host = settings.SERVER_HOST
63-
link = f"{server_host}/reset-password?token={token}"
62+
link = f"{settings.server_host}/reset-password?token={token}"
6463
html_content = render_email_template(
6564
template_name="reset_password.html",
6665
context={
@@ -79,15 +78,14 @@ def generate_new_account_email(
7978
) -> EmailData:
8079
project_name = settings.PROJECT_NAME
8180
subject = f"{project_name} - New account for user {username}"
82-
link = settings.SERVER_HOST
8381
html_content = render_email_template(
8482
template_name="new_account.html",
8583
context={
8684
"project_name": settings.PROJECT_NAME,
8785
"username": username,
8886
"password": password,
8987
"email": email_to,
90-
"link": link,
88+
"link": settings.server_host,
9189
},
9290
)
9391
return EmailData(html_content=html_content, subject=subject)

copier.yml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,3 @@
1-
domain:
2-
type: str
3-
help: |
4-
Which domain name to use for the project, by default,
5-
localhost, but you should change it later (in .env)
6-
default: localhost
7-
81
project_name:
92
type: str
103
help: The name of the project, shown to API users (in .env)

0 commit comments

Comments
 (0)