Skip to content

Commit c5cd1ba

Browse files
authored
Merge pull request #35 from whythawk/upgrade-path-to-pydantic-2.0
Complete update of stack to latest long-term releases. - `frontend`: - Node 16 -> 18 - Nuxt 3.2 -> 3.6.5 - Latest Pinia requires changes in stores, where imports are not required (cause actual errors), and parameter declaration must happen in functions. - `backend` and `celeryworker`: - Python 3.9 -> 3.11 - FastAPI 0.88 -> 0.99 (Inboard 0.37 -> 0.51) - Poetry -> Hatch - Postgres 14 -> 15
2 parents 8e291b2 + d991ee5 commit c5cd1ba

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+2727
-5035
lines changed

README.md

+31-5
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ Accelerate your next web development project with this FastAPI/Nuxt.js base proj
66

77
This project is for developers looking to build and maintain full-feature progressive web applications using Python on the backend / Typescript on the frontend, and want the complex-but-routine aspects of auth 'n auth, and component and deployment configuration, taken care of, including interactive API documentation.
88

9-
This is a comprehensively updated fork of [Sebastián Ramírez's](https://github.com/tiangolo) [Full Stack FastAPI and PostgreSQL Base Project Generator](https://github.com/tiangolo/full-stack-fastapi-postgresql). FastAPI is updated to version 0.88 (November 2022), SQLAlchemy to version 2.0 (January 2023), and the frontend to Nuxt 3.2 (February 2023).
9+
This is a comprehensively updated fork of [Sebastián Ramírez's](https://github.com/tiangolo) [Full Stack FastAPI and PostgreSQL Base Project Generator](https://github.com/tiangolo/full-stack-fastapi-postgresql). FastAPI is updated to version 0.99 (July 2023), SQLAlchemy to version 2.0 (July 2023), and the frontend to Nuxt 3.6 (July 2023).
1010

1111
- [Screenshots](#screenshots)
1212
- [Key features](#key-features)
1313
- [How to use it](#how-to-use-it)
14-
- [Getting started](#getting-started)
15-
- [Development and installation](#development-and-installation)
16-
- [Deployment for production](#deployment-for-production)
17-
- [Authentication and magic tokens](#authentication-and-magic-tokens)
14+
- [Getting started](./docs/getting-started.md)
15+
- [Development and installation](./docs/development-guide.md)
16+
- [Deployment for production](./docs/deployment-guide.md)
17+
- [Authentication and magic tokens](./docs/authentication-guide.md)
1818
- [More details](#more-details)
1919
- [Release notes](#release-notes)
2020
- [License](#license)
@@ -80,10 +80,36 @@ This FastAPI, PostgreSQL, Neo4j & Nuxt 3 repo will generate a complete web appli
8080

8181
After using this generator, your new project (the directory created) will contain an extensive `README.md` with instructions for development, deployment, etc. You can pre-read [the project `README.md` template here too](./{{cookiecutter.project_slug}}/README.md).
8282

83+
This current release (August 2023) is for FastAPI version 0.99 and is the last before introducing support for Pydantic 2. Since this is intended as a base stack on which you will build complex applications, there is no intention of backwards compatability between releases, and the objective is to ensure that each release has the latest long-term-support versions of the core libraries so that you can rely on your application core for as long as possible.
84+
85+
To align with [Inboard](https://inboard.bws.bio/), Poetry has been deprecated in favour of [Hatch](https://hatch.pypa.io/latest/). This will also, hopefully, sort out some Poetry-related Docker build errors.
86+
87+
## Help needed
88+
89+
The tests are broken and it would be great if someone could take that on. Other potential roadmap items:
90+
91+
- Translation: docs are all in English and it would be great if those could be in other languages.
92+
- Internationalisation: I am working on adding [nuxt/i18n](https://v8.i18n.nuxtjs.org/), but the Nuxt3 version is still pre-release.
93+
- PWA: Would be good to review the Vite [PWA](https://vite-pwa-org.netlify.app/) plugin.
94+
8395
## Release Notes
8496

8597
See notes and [releases](https://github.com/whythawk/full-stack-fastapi-postgresql/releases).
8698

99+
### 0.7.4
100+
- Updates: Complete update of stack to latest long-term releases. [#35](https://github.com/whythawk/full-stack-fastapi-postgresql/pull/35) by @turukawa, review by @br3ndonland
101+
- `frontend`:
102+
- Node 16 -> 18
103+
- Nuxt 3.2 -> 3.6.5
104+
- Latest Pinia requires changes in stores, where imports are not required (cause actual errors), and parameter declaration must happen in functions.
105+
- `backend` and `celeryworker`:
106+
- Python 3.9 -> 3.11
107+
- FastAPI 0.88 -> 0.99 (Inboard 0.37 -> 0.51)
108+
- Poetry -> Hatch
109+
- Postgres 14 -> 15
110+
- Fixed: Updated token url in deps.py [#29](https://github.com/whythawk/full-stack-fastapi-postgresql/pull/29) by @vusa
111+
- Docs: Reorganised documentation [#21](https://github.com/whythawk/full-stack-fastapi-postgresql/pull/21) by @turukawa
112+
87113
### 0.7.3
88114
- @nuxt/content 2.2.1 -> 2.4.3
89115
- Fixed: `@nuxt/content` default api, `/api/_content`, conflicts with the `backend` api url preventing content pages loading.
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.9.4
1+
3.11.0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Token remove to invalidate
2+
3+
Revision ID: fb120f8fc198
4+
Revises: 8188d671489a
5+
Create Date: 2023-07-25 11:39:26.423122
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
from sqlalchemy.dialects import postgresql
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "fb120f8fc198"
14+
down_revision = "8188d671489a"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.alter_column("token", "authenticates_id",
22+
existing_type=sa.UUID(),
23+
nullable=False)
24+
op.drop_column("token", "is_valid")
25+
op.alter_column("user", "created",
26+
existing_type=postgresql.TIMESTAMP(),
27+
type_=sa.DateTime(timezone=True),
28+
existing_nullable=False,
29+
existing_server_default=sa.text("now()"))
30+
op.alter_column("user", "modified",
31+
existing_type=postgresql.TIMESTAMP(),
32+
type_=sa.DateTime(timezone=True),
33+
existing_nullable=False,
34+
existing_server_default=sa.text("now()"))
35+
op.alter_column("user", "email_validated",
36+
existing_type=sa.BOOLEAN(),
37+
nullable=False)
38+
op.alter_column("user", "is_active",
39+
existing_type=sa.BOOLEAN(),
40+
nullable=False)
41+
op.alter_column("user", "is_superuser",
42+
existing_type=sa.BOOLEAN(),
43+
nullable=False)
44+
# ### end Alembic commands ###
45+
46+
47+
def downgrade():
48+
# ### commands auto generated by Alembic - please adjust! ###
49+
op.alter_column("user", "is_superuser",
50+
existing_type=sa.BOOLEAN(),
51+
nullable=True)
52+
op.alter_column("user", "is_active",
53+
existing_type=sa.BOOLEAN(),
54+
nullable=True)
55+
op.alter_column("user", "email_validated",
56+
existing_type=sa.BOOLEAN(),
57+
nullable=True)
58+
op.alter_column("user", "modified",
59+
existing_type=sa.DateTime(timezone=True),
60+
type_=postgresql.TIMESTAMP(),
61+
existing_nullable=False,
62+
existing_server_default=sa.text("now()"))
63+
op.alter_column("user", "created",
64+
existing_type=sa.DateTime(timezone=True),
65+
type_=postgresql.TIMESTAMP(),
66+
existing_nullable=False,
67+
existing_server_default=sa.text("now()"))
68+
op.add_column("token", sa.Column("is_valid", sa.BOOLEAN(), autoincrement=False, nullable=True))
69+
op.alter_column("token", "authenticates_id",
70+
existing_type=sa.UUID(),
71+
nullable=True)
72+
# ### end Alembic commands ###
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__="0.1.0"

{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/api.py

-2
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@
44
login,
55
users,
66
proxy,
7-
services,
87
)
98

109
api_router = APIRouter()
1110
api_router.include_router(login.router, prefix="/login", tags=["login"])
1211
api_router.include_router(users.router, prefix="/users", tags=["users"])
1312
api_router.include_router(proxy.router, prefix="/proxy", tags=["proxy"])
14-
api_router.include_router(services.router, prefix="/service", tags=["service"])

{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/login.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from typing import Any, Union, Dict
2-
from pydantic import EmailStr
32

43
from fastapi import APIRouter, Body, Depends, HTTPException
54
from fastapi.security import OAuth2PasswordRequestForm
@@ -34,7 +33,7 @@
3433

3534

3635
@router.post("/magic/{email}", response_model=schemas.WebToken)
37-
def login_with_magic_link(*, db: Session = Depends(deps.get_db), email: EmailStr) -> Any:
36+
def login_with_magic_link(*, db: Session = Depends(deps.get_db), email: str) -> Any:
3837
"""
3938
First step of a 'magic link' login. Check if the user exists and generate a magic link. Generates two short-duration
4039
jwt tokens, one for validation, one for email. Creates user if not exist.

{{cookiecutter.project_slug}}/backend/app/app/api/api_v1/endpoints/users.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,13 @@ def read_user(
8686
def read_all_users(
8787
*,
8888
db: Session = Depends(deps.get_db),
89-
skip: int = 0,
90-
limit: int = 100,
89+
page: int = 0,
9190
current_user: models.User = Depends(deps.get_current_active_superuser),
9291
) -> Any:
9392
"""
9493
Retrieve all current users.
9594
"""
96-
return crud.user.get_multi(db=db, skip=skip, limit=limit)
95+
return crud.user.get_multi(db=db, page=page)
9796

9897

9998
@router.post("/new-totp", response_model=schemas.NewTOTP)

{{cookiecutter.project_slug}}/backend/app/app/api/deps.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,12 @@ def get_refresh_user(db: Session = Depends(get_db), token: str = Depends(reusabl
8888
raise HTTPException(status_code=400, detail="Inactive user")
8989
# Check and revoke this refresh token
9090
token_obj = crud.token.get(token=token, user=user)
91-
if not token_obj or not token_obj.is_valid:
91+
if not token_obj:
9292
raise HTTPException(
9393
status_code=status.HTTP_403_FORBIDDEN,
9494
detail="Could not validate credentials",
9595
)
96-
crud.token.cancel_refresh_token(db, db_obj=token_obj)
96+
crud.token.remove(db, db_obj=token_obj)
9797
return user
9898

9999

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
from fastapi import WebSocket
3+
from starlette.websockets import WebSocketDisconnect
4+
from websockets.exceptions import ConnectionClosedError
5+
6+
7+
async def send_response(*, websocket: WebSocket, response: dict):
8+
try:
9+
await websocket.send_json(response)
10+
return True
11+
except (WebSocketDisconnect, ConnectionClosedError):
12+
return False
13+
14+
15+
async def receive_request(*, websocket: WebSocket) -> dict:
16+
try:
17+
return await websocket.receive_json()
18+
except (WebSocketDisconnect, ConnectionClosedError):
19+
return {}
20+
21+
22+
def sanitize_data_request(data: any) -> any:
23+
# Putting here for want of a better place
24+
if isinstance(data, (list, tuple, set)):
25+
return type(data)(sanitize_data_request(x) for x in data if x or isinstance(x, bool))
26+
elif isinstance(data, dict):
27+
return type(data)(
28+
(sanitize_data_request(k), sanitize_data_request(v))
29+
for k, v in data.items()
30+
if k and v or isinstance(v, bool)
31+
)
32+
else:
33+
return data

{{cookiecutter.project_slug}}/backend/app/app/core/config.py

+6
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ def sentry_dsn_can_be_blank(cls, v: str) -> Optional[str]:
3838
return None
3939
return v
4040

41+
# GENERAL SETTINGS
42+
43+
MULTI_MAX: int = 20
44+
45+
# COMPONENT SETTINGS
46+
4147
POSTGRES_SERVER: str
4248
POSTGRES_USER: str
4349
POSTGRES_PASSWORD: str

{{cookiecutter.project_slug}}/backend/app/app/crud/base.py

+8-4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sqlalchemy.orm import Session
66

77
from app.db.base_class import Base
8+
from app.core.config import settings
89

910
ModelType = TypeVar("ModelType", bound=Base)
1011
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
@@ -26,10 +27,13 @@ def __init__(self, model: Type[ModelType]):
2627
def get(self, db: Session, id: Any) -> Optional[ModelType]:
2728
return db.query(self.model).filter(self.model.id == id).first()
2829

29-
def get_multi(
30-
self, db: Session, *, skip: int = 0, limit: int = 100
31-
) -> List[ModelType]:
32-
return db.query(self.model).offset(skip).limit(limit).all()
30+
def get_multi(self, db: Session, *, page: int = 0, page_break: bool = False) -> list[ModelType]:
31+
db_objs = db.query(self.model)
32+
if not page_break:
33+
if page > 0:
34+
db_objs = db_objs.offset(page * settings.MULTI_MAX)
35+
db_objs = db_objs.limit(settings.MULTI_MAX)
36+
return db_objs.all()
3337

3438
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
3539
obj_in_data = jsonable_encoder(obj_in)
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,35 @@
11
from __future__ import annotations
22
from sqlalchemy.orm import Session
3-
from typing import List
4-
from sqlalchemy import and_
53

64
from app.crud.base import CRUDBase
75
from app.models import User, Token
86
from app.schemas import RefreshTokenCreate, RefreshTokenUpdate
7+
from app.core.config import settings
98

109

1110
class CRUDToken(CRUDBase[Token, RefreshTokenCreate, RefreshTokenUpdate]):
1211
# Everything is user-dependent
13-
def create(self, db: Session, *, obj_in: str, user_obj: User) -> User:
12+
def create(self, db: Session, *, obj_in: str, user_obj: User) -> Token:
1413
db_obj = db.query(self.model).filter(self.model.token == obj_in).first()
15-
if db_obj and db_obj.authenticates == user_obj:
16-
# In case the token was invalidated, then recreated with the same token key
17-
setattr(db_obj, "is_valid", True)
18-
db.add(db_obj)
19-
db.commit()
20-
db.refresh(db_obj)
21-
return db_obj
2214
if db_obj and db_obj.authenticates != user_obj:
23-
raise ValueError(f"Token mismatch between key and user.")
24-
db_obj = Token(token=obj_in)
25-
db.add(db_obj)
26-
db.commit()
27-
db.refresh(db_obj)
28-
user_obj.refresh_tokens.append(db_obj)
29-
db.commit()
30-
db.refresh(db_obj)
31-
return db_obj
32-
33-
def cancel_refresh_token(self, db: Session, *, db_obj: Token) -> Token:
34-
setattr(db_obj, "is_valid", False)
35-
db.add(db_obj)
36-
db.commit()
37-
db.refresh(db_obj)
38-
return db_obj
15+
raise ValueError("Token mismatch between key and user.")
16+
obj_in = RefreshTokenCreate(**{"token": obj_in, "authenticates_id": user_obj.id})
17+
return super().create(db=db, obj_in=obj_in)
3918

4019
def get(self, *, user: User, token: str) -> Token:
41-
return user.refresh_tokens.filter(and_(self.model.token == token, self.model.is_valid == True)).first()
42-
43-
def get_multi(self, *, user: User, skip: int = 0, limit: int = 100) -> List[Token]:
44-
return user.refresh_tokens.filter(self.model.is_valid == True).offset(skip).limit(limit).all()
45-
20+
return user.refresh_tokens.filter(self.model.token == token).first()
21+
22+
def get_multi(self, *, user: User, page: int = 0, page_break: bool = False) -> list[Token]:
23+
db_objs = user.refresh_tokens
24+
if not page_break:
25+
if page > 0:
26+
db_objs = db_objs.offset(page * settings.MULTI_MAX)
27+
db_objs = db_objs.limit(settings.MULTI_MAX)
28+
return db_objs.all()
29+
30+
def remove(self, db: Session, *, db_obj: Token) -> None:
31+
db.delete(db_obj)
32+
db.commit()
33+
return None
4634

4735
token = CRUDToken(Token)

{{cookiecutter.project_slug}}/backend/app/app/crud/crud_user.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
1616
def create(self, db: Session, *, obj_in: UserCreate) -> User:
1717
db_obj = User(
1818
email=obj_in.email,
19+
hashed_password=get_password_hash(obj_in.password),
1920
full_name=obj_in.full_name,
2021
is_superuser=obj_in.is_superuser,
2122
)
22-
if obj_in.password:
23-
db_obj.hashed_password = get_password_hash(obj_in.password)
2423
db.add(db_obj)
2524
db.commit()
2625
db.refresh(db_obj)
@@ -77,6 +76,11 @@ def toggle_user_state(self, db: Session, *, obj_in: Union[UserUpdate, Dict[str,
7776
return None
7877
return self.update(db=db, db_obj=db_obj, obj_in=obj_in)
7978

79+
def has_password(self, user: User) -> bool:
80+
if user.hashed_password:
81+
return True
82+
return False
83+
8084
def is_active(self, user: User) -> bool:
8185
return user.is_active
8286

{{cookiecutter.project_slug}}/backend/app/app/models/token.py

-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,5 @@
1212

1313
class Token(Base):
1414
token: Mapped[str] = mapped_column(primary_key=True, index=True)
15-
is_valid: Mapped[bool] = mapped_column(default=True)
1615
authenticates_id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("user.id"))
1716
authenticates: Mapped["User"] = relationship(back_populates="refresh_tokens")

{{cookiecutter.project_slug}}/backend/app/app/models/user.py

+13-4
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,22 @@
1717
class User(Base):
1818
id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid4)
1919
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
20-
modified: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), server_onupdate=func.now(), nullable=False)
21-
full_name: Mapped[str] = mapped_column(index=True)
20+
modified: Mapped[datetime] = mapped_column(
21+
DateTime(timezone=True),
22+
server_default=func.now(),
23+
server_onupdate=func.now(),
24+
nullable=False,
25+
)
26+
# METADATA
27+
full_name: Mapped[str] = mapped_column(index=True, nullable=True)
2228
email: Mapped[str] = mapped_column(unique=True, index=True, nullable=False)
2329
hashed_password: Mapped[Optional[str]] = mapped_column(nullable=True)
30+
# AUTHENTICATION AND PERSISTENCE
2431
totp_secret: Mapped[Optional[str]] = mapped_column(nullable=True)
25-
totp_counter: Mapped[Optional[str]] = mapped_column(nullable=True)
32+
totp_counter: Mapped[Optional[int]] = mapped_column(nullable=True)
2633
email_validated: Mapped[bool] = mapped_column(default=False)
2734
is_active: Mapped[bool] = mapped_column(default=True)
2835
is_superuser: Mapped[bool] = mapped_column(default=False)
29-
refresh_tokens: Mapped[list["Token"]] = relationship(back_populates="authenticates", lazy="dynamic")
36+
refresh_tokens: Mapped[list["Token"]] = relationship(
37+
foreign_keys="[Token.authenticates_id]", back_populates="authenticates", lazy="dynamic"
38+
)

0 commit comments

Comments
 (0)