Implement identity service with flexible authentication

This commit is contained in:
Automated Action 2025-06-05 10:07:49 +00:00
parent ae704f2ab4
commit 521be496ef
33 changed files with 1068 additions and 2 deletions

137
README.md
View File

@ -1,3 +1,136 @@
# FastAPI Application # Identity Service API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A FastAPI-based API for managing user identities and authentication that supports flexible login with either username or email.
## Features
- User registration with either email or username
- Flexible authentication (login with either email or username)
- JWT token-based authentication
- Password hashing with bcrypt
- SQLite database with SQLAlchemy ORM
- Database migrations with Alembic
- API documentation with Swagger UI and ReDoc
## Requirements
- Python 3.8+
- FastAPI
- SQLAlchemy
- Alembic
- PyJWT
- PassLib
- Uvicorn
- Email validator
## Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/identityservice.git
cd identityservice
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Set up environment variables (optional):
Create a `.env` file in the root directory with the following variables:
```
SECRET_KEY=your-secret-key
ACCESS_TOKEN_EXPIRE_MINUTES=30
```
## Database Setup
The application uses SQLite by default. The database will be created at `/app/storage/db/db.sqlite`.
To apply migrations:
```bash
alembic upgrade head
```
## Running the Application
Start the server:
```bash
uvicorn main:app --reload
```
The API will be available at http://localhost:8000
## API Documentation
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
- OpenAPI JSON: http://localhost:8000/openapi.json
## API Endpoints
### Authentication
- `POST /api/v1/auth/login` - OAuth2 compatible login (accepts username or email in the username field)
- `POST /api/v1/auth/login/flexible` - Login with separate email and username fields
### Users
- `POST /api/v1/users/` - Create a new user
- `GET /api/v1/users/me` - Get current user info
- `PUT /api/v1/users/me` - Update current user info
### Health Check
- `GET /health` - API health check
## Environment Variables
| Variable | Description | Default |
| --- | --- | --- |
| `SECRET_KEY` | JWT secret key | Auto-generated secure token |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT token expiration time | 30 minutes |
| `BACKEND_CORS_ORIGINS` | CORS allowed origins | ["*"] |
## Project Structure
```
.
├── alembic.ini
├── app
│ ├── api
│ │ ├── api_v1
│ │ │ ├── api.py
│ │ │ └── endpoints
│ │ │ ├── auth.py
│ │ │ └── users.py
│ │ └── deps.py
│ ├── core
│ │ ├── config.py
│ │ └── security.py
│ ├── crud
│ │ └── user.py
│ ├── db
│ │ ├── base.py
│ │ ├── base_class.py
│ │ └── session.py
│ ├── models
│ │ └── user.py
│ └── schemas
│ ├── auth.py
│ └── user.py
├── main.py
├── migrations
│ ├── env.py
│ ├── README
│ ├── script.py.mako
│ └── versions
│ └── 001_create_user_table.py
└── requirements.txt
```

85
alembic.ini Normal file
View File

@ -0,0 +1,85 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# 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 migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# SQLite URL example
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[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
# 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

0
app/__init__.py Normal file
View File

0
app/api/__init__.py Normal file
View File

View File

7
app/api/api_v1/api.py Normal file
View File

@ -0,0 +1,7 @@
from fastapi import APIRouter
from app.api.api_v1.endpoints import auth, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])

View File

View File

@ -0,0 +1,84 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app import crud, schemas
from app.api import deps
from app.core import security
from app.core.config import settings
from app.schemas.auth import LoginRequest
router = APIRouter()
@router.post("/login", response_model=schemas.Token)
async def login_access_token(
db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests using username/password
"""
# For OAuth2 form, username field can contain either email or username
identifier = form_data.username
user = None
# Try to authenticate with email
if "@" in identifier:
user = crud.user.authenticate(db, email=identifier, password=form_data.password)
else:
# Try to authenticate with username
user = crud.user.authenticate(db, username=identifier, password=form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username/email or password",
)
elif not crud.user.is_active(user):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user"
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": security.create_access_token(
user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}
@router.post("/login/flexible", response_model=schemas.Token)
async def login_flexible(
login_data: LoginRequest, db: Session = Depends(deps.get_db)
) -> Any:
"""
Login with either email or username
"""
user = crud.user.authenticate(
db,
email=login_data.email,
username=login_data.username,
password=login_data.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username/email or password",
)
elif not crud.user.is_active(user):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user"
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": security.create_access_token(
user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}

View File

@ -0,0 +1,90 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, status
from pydantic import EmailStr
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
router = APIRouter()
@router.post("/", response_model=schemas.User)
def create_user(
*,
db: Session = Depends(deps.get_db),
user_in: schemas.UserCreate,
) -> Any:
"""
Create new user with either email or username
"""
# Check if user exists with provided email
if user_in.email:
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Check if user exists with provided username
if user_in.username:
user = crud.user.get_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
# Create new user
user = crud.user.create(db, obj_in=user_in)
return user
@router.get("/me", response_model=schemas.User)
def read_user_me(
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get current user
"""
return current_user
@router.put("/me", response_model=schemas.User)
def update_user_me(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_user),
email: EmailStr = Body(None),
username: str = Body(None),
password: str = Body(None),
) -> Any:
"""
Update current user
"""
user_in = schemas.UserUpdate(
email=email, username=username, password=password
)
# If updating email, check it's not already taken
if email and email != current_user.email:
user = crud.user.get_by_email(db, email=email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# If updating username, check it's not already taken
if username and username != current_user.username:
user = crud.user.get_by_username(db, username=username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
user = crud.user.update(db, db_obj=current_user, obj_in=user_in)
return user

73
app/api/deps.py Normal file
View File

@ -0,0 +1,73 @@
from typing import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.core import security
from app.core.config import settings
from app.db.session import SessionLocal
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
def get_db() -> Generator:
"""
Dependency for getting DB session
"""
try:
db = SessionLocal()
yield db
finally:
db.close()
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
) -> models.User:
"""
Dependency for getting current user from JWT token
"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = schemas.TokenPayload(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
user = crud.user.get_by_id(db, id=token_data.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
def get_current_active_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
"""
Dependency for getting current active user
"""
if not crud.user.is_active(current_user):
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def get_current_active_superuser(
current_user: models.User = Depends(get_current_user),
) -> models.User:
"""
Dependency for getting current superuser
"""
if not crud.user.is_superuser(current_user):
raise HTTPException(
status_code=400, detail="The user doesn't have enough privileges"
)
return current_user

0
app/core/__init__.py Normal file
View File

30
app/core/config.py Normal file
View File

@ -0,0 +1,30 @@
import secrets
from pathlib import Path
from typing import List
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
PROJECT_NAME: str = "Identity Service"
API_V1_STR: str = "/api/v1"
# SECURITY
SECRET_KEY: str = secrets.token_urlsafe(32)
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# CORS
BACKEND_CORS_ORIGINS: List[str] = ["*"]
# Database
DB_DIR: Path = Path("/app") / "storage" / "db"
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()
# Create DB directory if it doesn't exist
settings.DB_DIR.mkdir(parents=True, exist_ok=True)

42
app/core/security.py Normal file
View File

@ -0,0 +1,42 @@
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def create_access_token(
subject: Union[str, Any], expires_delta: Optional[timedelta] = None
) -> str:
"""
Create a JWT access token
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against a hash
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password
"""
return pwd_context.hash(password)

0
app/crud/__init__.py Normal file
View File

113
app/crud/user.py Normal file
View File

@ -0,0 +1,113 @@
from typing import Any, Dict, Optional, Union
from sqlalchemy.orm import Session
from app.core.security import get_password_hash, verify_password
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
def get_by_email(db: Session, email: str) -> Optional[User]:
"""
Get a user by email
"""
return db.query(User).filter(User.email == email).first()
def get_by_username(db: Session, username: str) -> Optional[User]:
"""
Get a user by username
"""
return db.query(User).filter(User.username == username).first()
def get_by_id(db: Session, id: int) -> Optional[User]:
"""
Get a user by ID
"""
return db.query(User).filter(User.id == id).first()
def get_by_email_or_username(
db: Session, email: Optional[str] = None, username: Optional[str] = None
) -> Optional[User]:
"""
Get a user by email or username
"""
if email:
return get_by_email(db, email)
if username:
return get_by_username(db, username)
return None
def create(db: Session, *, obj_in: UserCreate) -> User:
"""
Create a new user
"""
db_obj = User(
email=obj_in.email,
username=obj_in.username,
hashed_password=get_password_hash(obj_in.password),
is_active=obj_in.is_active,
is_superuser=obj_in.is_superuser,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
) -> User:
"""
Update a user
"""
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.dict(exclude_unset=True)
# Handle password update
if "password" in update_data:
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
for field in update_data:
if hasattr(db_obj, field):
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def authenticate(
db: Session, *, email: Optional[str] = None, username: Optional[str] = None, password: str
) -> Optional[User]:
"""
Authenticate a user with email/username and password
"""
user = get_by_email_or_username(db, email=email, username=username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active(user: User) -> bool:
"""
Check if a user is active
"""
return user.is_active
def is_superuser(user: User) -> bool:
"""
Check if a user is a superuser
"""
return user.is_superuser

0
app/db/__init__.py Normal file
View File

5
app/db/base.py Normal file
View File

@ -0,0 +1,5 @@
# Import all the models, so that Base has them before being
# imported by Alembic
from app.db.base_class import Base # noqa
# Import your models here so they are registered with SQLAlchemy
from app.models.user import User # noqa

15
app/db/base_class.py Normal file
View File

@ -0,0 +1,15 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, Integer
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()

29
app/db/session.py Normal file
View File

@ -0,0 +1,29 @@
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# Create DB directory if it doesn't exist
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependency
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

0
app/models/__init__.py Normal file
View File

40
app/models/user.py Normal file
View File

@ -0,0 +1,40 @@
import re
from sqlalchemy import Boolean, Column, String
from sqlalchemy.orm import validates
from app.db.base_class import Base
class User(Base):
"""
User model with flexible authentication (username or email).
"""
username = Column(String, unique=True, index=True, nullable=True)
email = Column(String, unique=True, index=True, nullable=True)
hashed_password = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
@validates('email')
def validate_email(self, key, email):
if email is None:
return email
# Simple email validation regex
if not re.match(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', email):
raise ValueError('Invalid email format')
return email
@validates('username')
def validate_username(self, key, username):
if username is None:
return username
# Username validation: alphanumeric + underscore, 3-20 chars
if not re.match(r'^[a-zA-Z0-9_]{3,20}$', username):
raise ValueError(
'Username must be 3-20 characters and contain only letters, numbers, '
'and underscores'
)
return username

0
app/schemas/__init__.py Normal file
View File

23
app/schemas/auth.py Normal file
View File

@ -0,0 +1,23 @@
from typing import Optional
from pydantic import BaseModel, EmailStr, root_validator
class LoginRequest(BaseModel):
"""
Login schema with flexible identifier (email or username)
"""
# Either email or username must be provided
email: Optional[EmailStr] = None
username: Optional[str] = None
password: str
@root_validator
def check_email_or_username(cls, values):
"""
Validate that either email or username is provided
"""
email, username = values.get("email"), values.get("username")
if not email and not username:
raise ValueError("Either email or username must be provided")
return values

81
app/schemas/user.py Normal file
View File

@ -0,0 +1,81 @@
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, validator
class UserBase(BaseModel):
"""
Base schema for user data
"""
email: Optional[EmailStr] = None
username: Optional[str] = Field(None, min_length=3, max_length=20, pattern=r'^[a-zA-Z0-9_]+$')
is_active: Optional[bool] = True
is_superuser: bool = False
@validator('username')
def username_not_empty(cls, v):
if v == "":
raise ValueError('Username cannot be empty string')
return v
@validator('email', 'username')
def check_one_field_present(cls, v, values):
# If this is the second field (email or username) and both are None, raise error
if 'email' in values and v is None and values['email'] is None:
raise ValueError('Either email or username must be provided')
if 'username' in values and v is None and values['username'] is None:
raise ValueError('Either email or username must be provided')
return v
class UserCreate(UserBase):
"""
Schema for creating a new user
"""
password: str = Field(..., min_length=8)
class UserUpdate(UserBase):
"""
Schema for updating a user
"""
password: Optional[str] = Field(None, min_length=8)
class UserInDBBase(UserBase):
"""
Schema for user in DB
"""
id: int
class Config:
orm_mode = True
class User(UserInDBBase):
"""
Schema for returning user information
"""
pass
class UserInDB(UserInDBBase):
"""
Schema with password hash (for internal use)
"""
hashed_password: str
class Token(BaseModel):
"""
Token schema
"""
access_token: str
token_type: str
class TokenPayload(BaseModel):
"""
Token payload schema
"""
sub: Optional[int] = None

0
app/services/__init__.py Normal file
View File

0
app/utils/__init__.py Normal file
View File

42
main.py Normal file
View File

@ -0,0 +1,42 @@
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.api_v1.api import api_router
from app.core.config import settings
app = FastAPI(
title="Identity Service API",
description="API for managing user identities and authentication",
version="0.1.0",
openapi_url="/openapi.json",
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_STR)
# Health check endpoint
@app.get("/health", tags=["Health"])
async def health_check():
return {"status": "healthy"}
# Root endpoint
@app.get("/", tags=["Root"])
async def root():
return {
"message": "Welcome to the Identity Service API",
"docs": "/docs",
"redoc": "/redoc",
}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration with SQLite.

82
migrations/env.py Normal file
View File

@ -0,0 +1,82 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# 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
from app.db.base import Base # noqa: E402
target_metadata = Base.metadata
# 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"},
render_as_batch=True,
)
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.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
is_sqlite = connection.dialect.name == 'sqlite'
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -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"}

View File

@ -0,0 +1,40 @@
"""create user table
Revision ID: 001
Revises:
Create Date: 2023-07-10 12:34:56.789012
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('username', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_superuser', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True)
def downgrade() -> None:
op.drop_index(op.f('ix_user_username'), table_name='user')
op.drop_index(op.f('ix_user_id'), table_name='user')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user')

16
pyproject.toml Normal file
View File

@ -0,0 +1,16 @@
[tool.ruff]
line-length = 100
target-version = "py38"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W"]
ignore = ["N805"] # Ignore N805 for cls methods in SQLAlchemy and Pydantic
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.isort]
known-first-party = ["app"]
[tool.ruff.lint.flake8-quotes]
docstring-quotes = "double"
inline-quotes = "double"

11
requirements.txt Normal file
View File

@ -0,0 +1,11 @@
fastapi>=0.100.0
uvicorn>=0.22.0
sqlalchemy>=2.0.0
alembic>=1.11.1
pydantic>=2.0.0
pydantic-settings>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
email-validator>=2.0.0
ruff>=0.1.0