From 6700a4e2ef58d135bf48e2f80ed02389147ade24 Mon Sep 17 00:00:00 2001 From: Kevin ANATOLE Date: Sun, 19 Feb 2023 02:47:14 +0000 Subject: [PATCH] Register Login Basic migrations with alembic Get items --- __init__.py | 0 alembic.ini | 105 ++++++++++++++++++ alembic/README | 1 + alembic/env.py | 82 ++++++++++++++ alembic/script.py.mako | 24 ++++ .../0c96f5d57afe_create_items_table.py | 31 ++++++ .../e5467b78d2a0_create_users_table.py | 31 ++++++ api/__init__.py | 0 api/config.py | 15 +++ api/database.py | 13 +++ api/routes/__init__.py | 0 api/routes/auth.py | 36 ++++++ api/routes/items.py | 29 +++++ api/routes/users.py | 27 +++++ crud/__init__.py | 0 crud/items.py | 16 +++ crud/users.py | 60 ++++++++++ deps.py | 56 ++++++++++ main.py | 23 ++-- models/__init__.py | 0 models/items.py | 17 +++ models/users.py | 16 +++ schemas/__init__.py | 0 schemas/items.py | 20 ++++ schemas/users.py | 23 ++++ 25 files changed, 616 insertions(+), 9 deletions(-) create mode 100644 __init__.py create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/0c96f5d57afe_create_items_table.py create mode 100644 alembic/versions/e5467b78d2a0_create_users_table.py create mode 100644 api/__init__.py create mode 100644 api/config.py create mode 100644 api/database.py create mode 100644 api/routes/__init__.py create mode 100644 api/routes/auth.py create mode 100644 api/routes/items.py create mode 100644 api/routes/users.py create mode 100644 crud/__init__.py create mode 100644 crud/items.py create mode 100644 crud/users.py create mode 100644 deps.py create mode 100644 models/__init__.py create mode 100644 models/items.py create mode 100644 models/users.py create mode 100644 schemas/__init__.py create mode 100644 schemas/items.py create mode 100644 schemas/users.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..7fa7c98 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,105 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..006eef2 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,82 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +from api.config import settings + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + alembic_config = config.get_section(config.config_ini_section) + alembic_config['sqlalchemy.url'] = settings.sqlalchemy_database_url + connectable = engine_from_config( + alembic_config, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0c96f5d57afe_create_items_table.py b/alembic/versions/0c96f5d57afe_create_items_table.py new file mode 100644 index 0000000..6b13841 --- /dev/null +++ b/alembic/versions/0c96f5d57afe_create_items_table.py @@ -0,0 +1,31 @@ +"""create items table + +Revision ID: 0c96f5d57afe +Revises: e5467b78d2a0 +Create Date: 2023-02-19 02:29:29.348094 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0c96f5d57afe' +down_revision = 'e5467b78d2a0' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'items', + sa.Column("id", sa.UUID(as_uuid=True), primary_key=True), + sa.Column("title", sa.String(255), nullable=False, index=True), + sa.Column("description", sa.Text, nullable=True), + sa.Column("owner_id", sa.UUID(as_uuid=True), nullable=False) + ) + op.create_foreign_key("fk_items_user", "items", "users", ["owner_id"], ["id"]) + + +def downgrade() -> None: + op.drop_table('items') diff --git a/alembic/versions/e5467b78d2a0_create_users_table.py b/alembic/versions/e5467b78d2a0_create_users_table.py new file mode 100644 index 0000000..fad8f60 --- /dev/null +++ b/alembic/versions/e5467b78d2a0_create_users_table.py @@ -0,0 +1,31 @@ +"""create users table + +Revision ID: e5467b78d2a0 +Revises: +Create Date: 2023-02-19 01:57:02.748963 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e5467b78d2a0' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.UUID(as_uuid=True), primary_key=True), + sa.Column("email", sa.String(255), unique=True, nullable=False), + sa.Column("display_name", sa.String(255), nullable=False), + sa.Column("hashed_password", sa.String(255), nullable=False), + sa.Column("is_active", sa.Boolean, nullable=False, default=True) + ) + + +def downgrade() -> None: + op.drop_table("users") diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..1dc4de8 --- /dev/null +++ b/api/config.py @@ -0,0 +1,15 @@ +from pydantic import BaseSettings + + +class Settings(BaseSettings): + app_name: str = "Fast API Template" + sqlalchemy_database_url: str + secret_key: str + algorithm: str + access_token_expire_minutes: int + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/api/database.py b/api/database.py new file mode 100644 index 0000000..da2495e --- /dev/null +++ b/api/database.py @@ -0,0 +1,13 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from api.config import settings + +engine = create_engine( + settings.sqlalchemy_database_url, connect_args={} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/routes/auth.py b/api/routes/auth.py new file mode 100644 index 0000000..19db0b6 --- /dev/null +++ b/api/routes/auth.py @@ -0,0 +1,36 @@ +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +import deps +from api.config import settings +from crud.users import authenticate_user, create_access_token, get_user_by_email, create_user +from schemas.users import UserCreate, User + +router = APIRouter() + + +@router.post("/token", response_model=deps.Token) +async def login_for_access_token(db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends()): + user = authenticate_user(db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) + access_token = create_access_token( + data={"sub": str(user.id)}, expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer"} + + +@router.post("/register", response_model=User) +async def register(user: UserCreate, db: Session = Depends(deps.get_db)): + db_user = get_user_by_email(db, email=user.email) + if db_user: + raise HTTPException(status_code=400, detail="Email already registered") + return create_user(db=db, user=user) diff --git a/api/routes/items.py b/api/routes/items.py new file mode 100644 index 0000000..8b1021f --- /dev/null +++ b/api/routes/items.py @@ -0,0 +1,29 @@ +from typing import List + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from crud.items import create_user_item, get_items +from schemas.items import Item, ItemCreate +from schemas.users import User +from deps import get_current_active_user, get_db + +router = APIRouter() + + +@router.get("/users/me/items/", response_model=List[Item]) +async def read_own_items(current_user: User = Depends(get_current_active_user)): + return current_user.items + + +@router.post("/users/{user_id}/items/", response_model=Item) +def create_item_for_user( + user_id: str, item: ItemCreate, db: Session = Depends(get_db) +): + return create_user_item(db=db, item=item, user_id=user_id) + + +@router.get("/items/", response_model=List[Item]) +def read_items(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + items = get_items(db, skip=skip, limit=limit) + return items diff --git a/api/routes/users.py b/api/routes/users.py new file mode 100644 index 0000000..873229d --- /dev/null +++ b/api/routes/users.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from crud.users import get_users, get_user +from deps import get_current_active_user, get_db +from schemas.users import User + +router = APIRouter() + + +@router.get("/users/me/", response_model=User) +async def read_users_me(current_user: User = Depends(get_current_active_user)): + return current_user + + +@router.get("/users/", response_model=list[User]) +def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + users = get_users(db, skip=skip, limit=limit) + return users + + +@router.get("/users/{user_id}", response_model=User) +def read_user(user_id: str, db: Session = Depends(get_db)): + db_user = get_user(db, user_id=user_id) + if db_user is None: + raise HTTPException(status_code=404, detail="User not found") + return db_user diff --git a/crud/__init__.py b/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crud/items.py b/crud/items.py new file mode 100644 index 0000000..a976db8 --- /dev/null +++ b/crud/items.py @@ -0,0 +1,16 @@ +from sqlalchemy.orm import Session + +from models.items import Item +from schemas.items import ItemCreate + + +def get_items(db: Session, skip: int = 0, limit: int = 100): + return db.query(Item).offset(skip).limit(limit).all() + + +def create_user_item(db: Session, item: ItemCreate, user_id: str): + db_item = Item(**item.dict(), owner_id=user_id) + db.add(db_item) + db.commit() + db.refresh(db_item) + return db_item diff --git a/crud/users.py b/crud/users.py new file mode 100644 index 0000000..8490589 --- /dev/null +++ b/crud/users.py @@ -0,0 +1,60 @@ +from datetime import timedelta, datetime + +from jose import jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from models.users import User +from schemas.users import UserCreate +from api.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def get_user(db: Session, user_id: str): + return db.query(User).filter(User.id == user_id).first() + + +def get_user_by_email(db: Session, email: str): + return db.query(User).filter(User.email == email).first() + + +def get_users(db: Session, skip: int = 0, limit: int = 100): + return db.query(User).offset(skip).limit(limit).all() + + +def create_user(db: Session, user: UserCreate): + hashed_password = get_password_hash(user.password) + db_user = User(email=user.email, hashed_password=hashed_password, display_name=user.display_name) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +def authenticate_user(db: Session, username: str, password: str): + user = get_user_by_email(db, username) + if not user: + return False + if not verify_password(password, user.hashed_password): + return False + return user + + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + + +def create_access_token(data: dict, expires_delta: timedelta | None = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + return encoded_jwt + + +def get_password_hash(password): + return pwd_context.hash(password) diff --git a/deps.py b/deps.py new file mode 100644 index 0000000..405636a --- /dev/null +++ b/deps.py @@ -0,0 +1,56 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from api.config import settings +from crud.users import get_user +from api.database import SessionLocal +from schemas.users import User + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + sub: str | None = None + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +async def get_current_user(db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + sub: str = payload.get("sub") + if sub is None: + raise credentials_exception + token_data = TokenData(sub=sub) + except JWTError: + raise credentials_exception + user = get_user(db, user_id=token_data.sub) + if user is None: + raise credentials_exception + return user + + +async def get_current_active_user(current_user: User = Depends(get_current_user)): + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user diff --git a/main.py b/main.py index d5c0b27..d21f955 100644 --- a/main.py +++ b/main.py @@ -1,14 +1,19 @@ -from typing import Union -from fastapi import FastAPI +from fastapi import FastAPI, APIRouter + +from api.routes import items, auth, users app = FastAPI() +api_router = APIRouter() +api_router.include_router(auth.router) + + +api_router.include_router(users.router) +api_router.include_router(items.router) + +app.include_router(api_router) + @app.get("/") -def read_root(): - return {"Hello": "World"} - - -@app.get("/items/{item_id}") -def read_item(item_id: int, q: Union[str, None] = None): - return {"item_id": item_id, "q": q} \ No newline at end of file +async def root(): + return {"message": "Hello World"} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/items.py b/models/items.py new file mode 100644 index 0000000..442dd48 --- /dev/null +++ b/models/items.py @@ -0,0 +1,17 @@ +from uuid import uuid4 + +from sqlalchemy import Column, ForeignKey, String, UUID +from sqlalchemy.orm import relationship + +from api.database import Base + + +class Item(Base): + __tablename__ = "items" + + id = Column(UUID, primary_key=True, index=True, default=uuid4) + title = Column(String, index=True) + description = Column(String, index=True) + owner_id = Column(UUID, ForeignKey("users.id")) + + owner = relationship("User", back_populates="items") diff --git a/models/users.py b/models/users.py new file mode 100644 index 0000000..d7691a4 --- /dev/null +++ b/models/users.py @@ -0,0 +1,16 @@ +from sqlalchemy import Boolean, Column, String, UUID +from sqlalchemy.orm import relationship + +from api.database import Base +from uuid import uuid4 + +class User(Base): + __tablename__ = "users" + + id = Column(UUID, primary_key=True, index=True, default=uuid4) + email = Column(String, unique=True, index=True) + display_name = Column(String) + hashed_password = Column(String) + is_active = Column(Boolean, default=True) + + items = relationship("Item", back_populates="owner") \ No newline at end of file diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schemas/items.py b/schemas/items.py new file mode 100644 index 0000000..0c1ec50 --- /dev/null +++ b/schemas/items.py @@ -0,0 +1,20 @@ +import uuid + +from pydantic import BaseModel + + +class ItemBase(BaseModel): + title: str + description: str | None = None + + +class ItemCreate(ItemBase): + pass + + +class Item(ItemBase): + id: uuid.UUID + owner_id: uuid.UUID + + class Config: + orm_mode = True diff --git a/schemas/users.py b/schemas/users.py new file mode 100644 index 0000000..e6e811f --- /dev/null +++ b/schemas/users.py @@ -0,0 +1,23 @@ +import uuid + +from pydantic import BaseModel + +from schemas.items import Item + + +class UserBase(BaseModel): + display_name: str + + +class UserCreate(UserBase): + password: str + email: str + + +class User(UserBase): + id: uuid.UUID + is_active: bool + items: list[Item] = [] + + class Config: + orm_mode = True