Skip to content

Commit

Permalink
Merge pull request #35 from whythawk/upgrade-path-to-pydantic-2.0
Browse files Browse the repository at this point in the history
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
  • Loading branch information
turukawa authored Aug 11, 2023
2 parents 8e291b2 + d991ee5 commit c5cd1ba
Show file tree
Hide file tree
Showing 47 changed files with 2,727 additions and 5,035 deletions.
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ Accelerate your next web development project with this FastAPI/Nuxt.js base proj

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.

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).
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).

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

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).

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.

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.

## Help needed

The tests are broken and it would be great if someone could take that on. Other potential roadmap items:

- Translation: docs are all in English and it would be great if those could be in other languages.
- Internationalisation: I am working on adding [nuxt/i18n](https://v8.i18n.nuxtjs.org/), but the Nuxt3 version is still pre-release.
- PWA: Would be good to review the Vite [PWA](https://vite-pwa-org.netlify.app/) plugin.

## Release Notes

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

### 0.7.4
- 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
- `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
- Fixed: Updated token url in deps.py [#29](https://github.com/whythawk/full-stack-fastapi-postgresql/pull/29) by @vusa
- Docs: Reorganised documentation [#21](https://github.com/whythawk/full-stack-fastapi-postgresql/pull/21) by @turukawa

### 0.7.3
- @nuxt/content 2.2.1 -> 2.4.3
- Fixed: `@nuxt/content` default api, `/api/_content`, conflicts with the `backend` api url preventing content pages loading.
Expand Down
2 changes: 1 addition & 1 deletion {{cookiecutter.project_slug}}/backend/app/.python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.9.4
3.11.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Token remove to invalidate
Revision ID: fb120f8fc198
Revises: 8188d671489a
Create Date: 2023-07-25 11:39:26.423122
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "fb120f8fc198"
down_revision = "8188d671489a"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column("token", "authenticates_id",
existing_type=sa.UUID(),
nullable=False)
op.drop_column("token", "is_valid")
op.alter_column("user", "created",
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"))
op.alter_column("user", "modified",
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
existing_server_default=sa.text("now()"))
op.alter_column("user", "email_validated",
existing_type=sa.BOOLEAN(),
nullable=False)
op.alter_column("user", "is_active",
existing_type=sa.BOOLEAN(),
nullable=False)
op.alter_column("user", "is_superuser",
existing_type=sa.BOOLEAN(),
nullable=False)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column("user", "is_superuser",
existing_type=sa.BOOLEAN(),
nullable=True)
op.alter_column("user", "is_active",
existing_type=sa.BOOLEAN(),
nullable=True)
op.alter_column("user", "email_validated",
existing_type=sa.BOOLEAN(),
nullable=True)
op.alter_column("user", "modified",
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text("now()"))
op.alter_column("user", "created",
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
existing_server_default=sa.text("now()"))
op.add_column("token", sa.Column("is_valid", sa.BOOLEAN(), autoincrement=False, nullable=True))
op.alter_column("token", "authenticates_id",
existing_type=sa.UUID(),
nullable=True)
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__="0.1.0"
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@
login,
users,
proxy,
services,
)

api_router = APIRouter()
api_router.include_router(login.router, prefix="/login", tags=["login"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(proxy.router, prefix="/proxy", tags=["proxy"])
api_router.include_router(services.router, prefix="/service", tags=["service"])
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from typing import Any, Union, Dict
from pydantic import EmailStr

from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
Expand Down Expand Up @@ -34,7 +33,7 @@


@router.post("/magic/{email}", response_model=schemas.WebToken)
def login_with_magic_link(*, db: Session = Depends(deps.get_db), email: EmailStr) -> Any:
def login_with_magic_link(*, db: Session = Depends(deps.get_db), email: str) -> Any:
"""
First step of a 'magic link' login. Check if the user exists and generate a magic link. Generates two short-duration
jwt tokens, one for validation, one for email. Creates user if not exist.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,13 @@ def read_user(
def read_all_users(
*,
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
page: int = 0,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Retrieve all current users.
"""
return crud.user.get_multi(db=db, skip=skip, limit=limit)
return crud.user.get_multi(db=db, page=page)


@router.post("/new-totp", response_model=schemas.NewTOTP)
Expand Down
4 changes: 2 additions & 2 deletions {{cookiecutter.project_slug}}/backend/app/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ def get_refresh_user(db: Session = Depends(get_db), token: str = Depends(reusabl
raise HTTPException(status_code=400, detail="Inactive user")
# Check and revoke this refresh token
token_obj = crud.token.get(token=token, user=user)
if not token_obj or not token_obj.is_valid:
if not token_obj:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
crud.token.cancel_refresh_token(db, db_obj=token_obj)
crud.token.remove(db, db_obj=token_obj)
return user


Expand Down
33 changes: 33 additions & 0 deletions {{cookiecutter.project_slug}}/backend/app/app/api/sockets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from __future__ import annotations
from fastapi import WebSocket
from starlette.websockets import WebSocketDisconnect
from websockets.exceptions import ConnectionClosedError


async def send_response(*, websocket: WebSocket, response: dict):
try:
await websocket.send_json(response)
return True
except (WebSocketDisconnect, ConnectionClosedError):
return False


async def receive_request(*, websocket: WebSocket) -> dict:
try:
return await websocket.receive_json()
except (WebSocketDisconnect, ConnectionClosedError):
return {}


def sanitize_data_request(data: any) -> any:
# Putting here for want of a better place
if isinstance(data, (list, tuple, set)):
return type(data)(sanitize_data_request(x) for x in data if x or isinstance(x, bool))
elif isinstance(data, dict):
return type(data)(
(sanitize_data_request(k), sanitize_data_request(v))
for k, v in data.items()
if k and v or isinstance(v, bool)
)
else:
return data
6 changes: 6 additions & 0 deletions {{cookiecutter.project_slug}}/backend/app/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ def sentry_dsn_can_be_blank(cls, v: str) -> Optional[str]:
return None
return v

# GENERAL SETTINGS

MULTI_MAX: int = 20

# COMPONENT SETTINGS

POSTGRES_SERVER: str
POSTGRES_USER: str
POSTGRES_PASSWORD: str
Expand Down
12 changes: 8 additions & 4 deletions {{cookiecutter.project_slug}}/backend/app/app/crud/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from sqlalchemy.orm import Session

from app.db.base_class import Base
from app.core.config import settings

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

def get_multi(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
return db.query(self.model).offset(skip).limit(limit).all()
def get_multi(self, db: Session, *, page: int = 0, page_break: bool = False) -> list[ModelType]:
db_objs = db.query(self.model)
if not page_break:
if page > 0:
db_objs = db_objs.offset(page * settings.MULTI_MAX)
db_objs = db_objs.limit(settings.MULTI_MAX)
return db_objs.all()

def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
obj_in_data = jsonable_encoder(obj_in)
Expand Down
50 changes: 19 additions & 31 deletions {{cookiecutter.project_slug}}/backend/app/app/crud/crud_token.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,35 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from typing import List
from sqlalchemy import and_

from app.crud.base import CRUDBase
from app.models import User, Token
from app.schemas import RefreshTokenCreate, RefreshTokenUpdate
from app.core.config import settings


class CRUDToken(CRUDBase[Token, RefreshTokenCreate, RefreshTokenUpdate]):
# Everything is user-dependent
def create(self, db: Session, *, obj_in: str, user_obj: User) -> User:
def create(self, db: Session, *, obj_in: str, user_obj: User) -> Token:
db_obj = db.query(self.model).filter(self.model.token == obj_in).first()
if db_obj and db_obj.authenticates == user_obj:
# In case the token was invalidated, then recreated with the same token key
setattr(db_obj, "is_valid", True)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
if db_obj and db_obj.authenticates != user_obj:
raise ValueError(f"Token mismatch between key and user.")
db_obj = Token(token=obj_in)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
user_obj.refresh_tokens.append(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj

def cancel_refresh_token(self, db: Session, *, db_obj: Token) -> Token:
setattr(db_obj, "is_valid", False)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
raise ValueError("Token mismatch between key and user.")
obj_in = RefreshTokenCreate(**{"token": obj_in, "authenticates_id": user_obj.id})
return super().create(db=db, obj_in=obj_in)

def get(self, *, user: User, token: str) -> Token:
return user.refresh_tokens.filter(and_(self.model.token == token, self.model.is_valid == True)).first()

def get_multi(self, *, user: User, skip: int = 0, limit: int = 100) -> List[Token]:
return user.refresh_tokens.filter(self.model.is_valid == True).offset(skip).limit(limit).all()

return user.refresh_tokens.filter(self.model.token == token).first()

def get_multi(self, *, user: User, page: int = 0, page_break: bool = False) -> list[Token]:
db_objs = user.refresh_tokens
if not page_break:
if page > 0:
db_objs = db_objs.offset(page * settings.MULTI_MAX)
db_objs = db_objs.limit(settings.MULTI_MAX)
return db_objs.all()

def remove(self, db: Session, *, db_obj: Token) -> None:
db.delete(db_obj)
db.commit()
return None

token = CRUDToken(Token)
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@ def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
def create(self, db: Session, *, obj_in: UserCreate) -> User:
db_obj = User(
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
is_superuser=obj_in.is_superuser,
)
if obj_in.password:
db_obj.hashed_password = get_password_hash(obj_in.password)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
Expand Down Expand Up @@ -77,6 +76,11 @@ def toggle_user_state(self, db: Session, *, obj_in: Union[UserUpdate, Dict[str,
return None
return self.update(db=db, db_obj=db_obj, obj_in=obj_in)

def has_password(self, user: User) -> bool:
if user.hashed_password:
return True
return False

def is_active(self, user: User) -> bool:
return user.is_active

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,5 @@

class Token(Base):
token: Mapped[str] = mapped_column(primary_key=True, index=True)
is_valid: Mapped[bool] = mapped_column(default=True)
authenticates_id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("user.id"))
authenticates: Mapped["User"] = relationship(back_populates="refresh_tokens")
17 changes: 13 additions & 4 deletions {{cookiecutter.project_slug}}/backend/app/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,22 @@
class User(Base):
id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid4)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
modified: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), server_onupdate=func.now(), nullable=False)
full_name: Mapped[str] = mapped_column(index=True)
modified: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
server_onupdate=func.now(),
nullable=False,
)
# METADATA
full_name: Mapped[str] = mapped_column(index=True, nullable=True)
email: Mapped[str] = mapped_column(unique=True, index=True, nullable=False)
hashed_password: Mapped[Optional[str]] = mapped_column(nullable=True)
# AUTHENTICATION AND PERSISTENCE
totp_secret: Mapped[Optional[str]] = mapped_column(nullable=True)
totp_counter: Mapped[Optional[str]] = mapped_column(nullable=True)
totp_counter: Mapped[Optional[int]] = mapped_column(nullable=True)
email_validated: Mapped[bool] = mapped_column(default=False)
is_active: Mapped[bool] = mapped_column(default=True)
is_superuser: Mapped[bool] = mapped_column(default=False)
refresh_tokens: Mapped[list["Token"]] = relationship(back_populates="authenticates", lazy="dynamic")
refresh_tokens: Mapped[list["Token"]] = relationship(
foreign_keys="[Token.authenticates_id]", back_populates="authenticates", lazy="dynamic"
)
Loading

0 comments on commit c5cd1ba

Please sign in to comment.