Add user authentication and user-specific invoice management

- Create User model and database schema
- Add JWT authentication with secure password hashing
- Create authentication endpoints for registration and login
- Update invoice routes to require authentication
- Ensure users can only access their own invoices
- Update documentation in README.md
This commit is contained in:
Automated Action 2025-05-30 09:00:26 +00:00
parent 040210f43f
commit 4e361e2d61
16 changed files with 608 additions and 23 deletions

View File

@ -1,14 +1,17 @@
# Invoice Generation Service
This is a FastAPI application for generating and managing invoices. It allows you to create, read, update, and delete invoices without requiring user signup.
This is a FastAPI application for generating and managing invoices. It allows users to register, login, and manage their invoices with full authentication and user-specific data access.
## Features
- User registration and authentication with JWT
- Generate invoices with automatic invoice number generation
- Store invoice details in SQLite database
- Retrieve invoice information by ID or invoice number
- Update invoice details and status
- Delete invoices
- User-specific invoice management (users can only access their own invoices)
- Advanced search and filtering options
- Web-based user interface for invoice management
- Health check endpoint
@ -18,6 +21,8 @@ This is a FastAPI application for generating and managing invoices. It allows yo
- **Database**: SQLite with SQLAlchemy ORM
- **Migrations**: Alembic
- **Validation**: Pydantic
- **Authentication**: JWT (JSON Web Tokens)
- **Password Hashing**: Bcrypt
- **Frontend**: Jinja2 Templates, HTML, CSS, JavaScript
- **Linting**: Ruff
@ -69,26 +74,41 @@ This is a FastAPI application for generating and managing invoices. It allows yo
- `GET /health`: Check if the service is running
### Authentication API
- `POST /api/v1/auth/register`: Register a new user
- Request body: User registration details (email, username, password, etc.)
- Returns: User details (without password)
- `POST /api/v1/auth/login`: Login and get an access token
- Request body: Form data with username/email and password
- Returns: JWT access token
- `GET /api/v1/auth/me`: Get current user details
- Headers: `Authorization: Bearer {token}`
- Returns: Current user details
### Invoice Management API
- `POST /api/v1/invoices`: Create a new invoice
- `GET /api/v1/invoices`: List all invoices (with pagination)
All invoice endpoints require authentication with a valid JWT token in the Authorization header: `Authorization: Bearer {token}`
- `POST /api/v1/invoices`: Create a new invoice (associated with the authenticated user)
- `GET /api/v1/invoices`: List invoices belonging to the authenticated user (with pagination)
- Query parameters:
- `skip`: Number of records to skip (default: 0)
- `limit`: Maximum number of records to return (default: 100)
- `fields`: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount")
- `status`: Filter invoices by status (e.g., "PENDING", "PAID", "CANCELLED")
- `sort_order`: Sort by creation date, either "asc" (oldest first) or "desc" (newest first, default)
- `GET /api/v1/invoices/{invoice_id}`: Get a specific invoice by ID
- Many more filtering options available (see Advanced Filtering section)
- `GET /api/v1/invoices/{invoice_id}`: Get a specific invoice by ID (must belong to the authenticated user)
- Query parameters:
- `fields`: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount")
- `GET /api/v1/invoices/find`: Find an invoice by invoice number
- `GET /api/v1/invoices/find`: Find an invoice by invoice number (must belong to the authenticated user)
- Query parameters:
- `invoice_number`: Invoice number to search for (required)
- `fields`: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount")
- `PATCH /api/v1/invoices/{invoice_id}`: Update an invoice
- `PATCH /api/v1/invoices/{invoice_id}/status`: Update invoice status
- `DELETE /api/v1/invoices/{invoice_id}`: Delete an invoice
- `PATCH /api/v1/invoices/{invoice_id}`: Update an invoice (must belong to the authenticated user)
- `PATCH /api/v1/invoices/{invoice_id}/status`: Update invoice status (must belong to the authenticated user)
- `DELETE /api/v1/invoices/{invoice_id}`: Delete an invoice (must belong to the authenticated user)
### Frontend Routes
@ -116,6 +136,69 @@ This is a FastAPI application for generating and managing invoices. It allows yo
uvicorn main:app --reload
```
## Authentication
The application uses JWT (JSON Web Tokens) for authentication. Here's how to use it:
### User Registration
To create a new user account:
```
POST /api/v1/auth/register
```
Request body:
```json
{
"email": "user@example.com",
"username": "user123",
"password": "securepassword",
"full_name": "John Doe"
}
```
### User Login
To log in and get an access token:
```
POST /api/v1/auth/login
```
This endpoint accepts form data (not JSON) with:
- `username`: Your username or email
- `password`: Your password
Response:
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
```
### Using the Token
Include the token in the Authorization header for all invoice-related requests:
```
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
### Getting Current User
To get the details of the currently authenticated user:
```
GET /api/v1/auth/me
```
Headers:
```
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
## API Documentation
Once the application is running, you can access:

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

@ -0,0 +1,69 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.core.security import decode_token
from app.models.user import User
# OAuth2 password bearer for token authentication
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
) -> User:
"""
Get the current authenticated user.
"""
try:
payload = decode_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user_id: int = int(payload.sub)
except (jwt.JWTError, ValidationError) as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
) from e
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
return user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get the current active user.
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
return current_user

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter
from app.api.routes import invoices
from app.api.routes import invoices, auth
api_router = APIRouter()
api_router.include_router(invoices.router, prefix="/invoices", tags=["invoices"])
api_router.include_router(invoices.router, prefix="/invoices", tags=["invoices"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])

110
app/api/routes/auth.py Normal file
View File

@ -0,0 +1,110 @@
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.api.deps import get_current_user
from app.core.config import settings
from app.core.database import get_db
from app.core.security import (
create_access_token,
get_password_hash,
verify_password,
)
from app.models.user import User
from app.schemas.token import Token
from app.schemas.user import User as UserSchema
from app.schemas.user import UserCreate
router = APIRouter()
@router.post("/register", response_model=UserSchema)
def register_user(
user_in: UserCreate,
db: Session = Depends(get_db),
) -> Any:
"""
Register a new user.
"""
# Check if user with this email already exists
user = db.query(User).filter(User.email == user_in.email).first()
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists.",
)
# Check if username already exists
user = db.query(User).filter(User.username == user_in.username).first()
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this username already exists.",
)
# Create new user
db_user = User(
email=user_in.email,
username=user_in.username,
hashed_password=get_password_hash(user_in.password),
full_name=user_in.full_name,
is_active=True,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.post("/login", response_model=Token)
def login_for_access_token(
db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
"""
# Try to find user by username first
user = db.query(User).filter(User.username == form_data.username).first()
# If not found, try by email
if not user:
user = db.query(User).filter(User.email == form_data.username).first()
# If still not found or wrong password
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
# Create access token
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
subject=user.id, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/me", response_model=UserSchema)
def read_users_me(
current_user: User = Depends(get_current_user),
) -> Any:
"""
Get current user.
"""
return current_user

View File

@ -6,9 +6,11 @@ from fastapi.responses import JSONResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.core.database import get_db
from app.core.utils import generate_invoice_number
from app.models.invoice import Invoice, InvoiceItem
from app.models.user import User
from app.schemas.invoice import (
InvoiceCreate,
InvoiceDB,
@ -22,9 +24,15 @@ router = APIRouter()
@router.post("/", response_model=InvoiceDB, status_code=status.HTTP_201_CREATED)
def create_invoice(invoice_data: InvoiceCreate, db: Session = Depends(get_db)):
def create_invoice(
invoice_data: InvoiceCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Create a new invoice.
Requires authentication. The invoice will be associated with the current user.
"""
# Generate unique invoice number
invoice_number = generate_invoice_number()
@ -40,6 +48,7 @@ def create_invoice(invoice_data: InvoiceCreate, db: Session = Depends(get_db)):
notes=invoice_data.notes,
status="PENDING",
total_amount=0, # Will be calculated from items
user_id=current_user.id, # Associate with current user
)
# Add to DB
@ -96,7 +105,8 @@ def get_invoices(
sort_by: Optional[str] = Query(None, description="Field to sort by (date_created, due_date, total_amount, customer_name)"),
sort_order: Optional[str] = Query("desc", description="Sort order: 'asc' for ascending, 'desc' for descending"),
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Retrieve a list of invoices with advanced filtering and sorting options.
@ -141,8 +151,8 @@ def get_invoices(
sort_order=sort_order,
)
# Build the query with filters
query = db.query(Invoice)
# Build the query with filters - only get invoices for the current user
query = db.query(Invoice).filter(Invoice.user_id == current_user.id)
# Apply status filter if provided
if status:
@ -223,11 +233,14 @@ def get_invoices(
def get_invoice(
invoice_id: int,
fields: Optional[str] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Retrieve a specific invoice by ID.
The invoice must belong to the authenticated user.
Parameters:
- invoice_id: ID of the invoice to retrieve
- fields: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount")
@ -240,6 +253,13 @@ def get_invoice(
detail=f"Invoice with ID {invoice_id} not found",
)
# Check that the invoice belongs to the current user
if invoice.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this invoice",
)
# If fields parameter is provided, filter the response
if fields:
# Process the response to include only the specified fields
@ -254,17 +274,25 @@ def get_invoice(
def find_invoice_by_number(
invoice_number: str,
fields: Optional[str] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Find an invoice by its invoice number.
The invoice must belong to the authenticated user.
Parameters:
- invoice_number: The invoice number to search for
- fields: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount")
If not provided, all fields will be returned.
"""
invoice = db.query(Invoice).filter(Invoice.invoice_number == invoice_number).first()
# Find the invoice and ensure it belongs to the current user
invoice = db.query(Invoice).filter(
Invoice.invoice_number == invoice_number,
Invoice.user_id == current_user.id
).first()
if invoice is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -285,10 +313,13 @@ def find_invoice_by_number(
def update_invoice(
invoice_id: int,
invoice_update: InvoiceUpdate,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Update an existing invoice.
The invoice must belong to the authenticated user.
"""
# Get the invoice
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
@ -298,6 +329,13 @@ def update_invoice(
detail=f"Invoice with ID {invoice_id} not found",
)
# Check that the invoice belongs to the current user
if invoice.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to update this invoice",
)
# Update invoice fields
update_data = invoice_update.dict(exclude_unset=True)
for field, value in update_data.items():
@ -314,10 +352,13 @@ def update_invoice(
def update_invoice_status(
invoice_id: int,
status_update: InvoiceStatusUpdate,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Update the status of an invoice.
The invoice must belong to the authenticated user.
"""
# Get the invoice
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
@ -327,6 +368,13 @@ def update_invoice_status(
detail=f"Invoice with ID {invoice_id} not found",
)
# Check that the invoice belongs to the current user
if invoice.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to update this invoice",
)
# Update status
invoice.status = status_update.status
@ -338,9 +386,15 @@ def update_invoice_status(
@router.delete("/{invoice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_invoice(invoice_id: int, db: Session = Depends(get_db)):
def delete_invoice(
invoice_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Delete an invoice.
The invoice must belong to the authenticated user.
"""
# Get the invoice
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
@ -350,6 +404,13 @@ def delete_invoice(invoice_id: int, db: Session = Depends(get_db)):
detail=f"Invoice with ID {invoice_id} not found",
)
# Check that the invoice belongs to the current user
if invoice.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete this invoice",
)
# Delete the invoice
db.delete(invoice)
db.commit()

View File

@ -1,4 +1,5 @@
from typing import List, Union
import secrets
from pydantic import AnyHttpUrl, validator
from pydantic_settings import BaseSettings
@ -11,6 +12,16 @@ class Settings(BaseSettings):
# CORS settings
BACKEND_CORS_ORIGINS: List[Union[str, AnyHttpUrl]] = []
# Security settings
SECRET_KEY: str = secrets.token_urlsafe(32)
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
# Set this to a real key in production environment
def __init__(self, **data):
super().__init__(**data)
if self.SECRET_KEY == "":
self.SECRET_KEY = secrets.token_urlsafe(32)
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:

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

@ -0,0 +1,57 @@
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from jose import jwt
from passlib.context import CryptContext
from pydantic import ValidationError
from app.core.config import settings
from app.schemas.token import TokenPayload
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 that a plain password matches a hashed password.
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password for storing.
"""
return pwd_context.hash(password)
def decode_token(token: str) -> Optional[TokenPayload]:
"""
Decode a JWT token and return the payload.
"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[ALGORITHM]
)
return TokenPayload(**payload)
except (jwt.JWTError, ValidationError):
return None

View File

@ -1 +1,2 @@
from app.models.invoice import Invoice, InvoiceItem # noqa: F401
from app.models.invoice import Invoice, InvoiceItem # noqa: F401
from app.models.user import User # noqa: F401

View File

@ -27,9 +27,15 @@ class Invoice(Base):
total_amount = Column(Float, nullable=False)
status = Column(String(50), default="PENDING", nullable=False)
notes = Column(Text, nullable=True)
# Foreign key to user
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Relationship with invoice items
items = relationship("InvoiceItem", back_populates="invoice", cascade="all, delete-orphan")
# Relationship with user
user = relationship("User", back_populates="invoices")
class InvoiceItem(Base):

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

@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
username = Column(String(50), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(100), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationship with invoices
invoices = relationship("Invoice", back_populates="user", cascade="all, delete-orphan")

View File

@ -7,5 +7,19 @@ from app.schemas.invoice import ( # noqa: F401
InvoiceItemCreate,
InvoiceItemDB,
InvoiceSearchQuery,
InvoiceStatusUpdate
InvoiceStatusUpdate,
InvoiceAdvancedFilter
)
from app.schemas.user import ( # noqa: F401
User,
UserCreate,
UserUpdate,
UserInDB,
UserBase
)
from app.schemas.token import ( # noqa: F401
Token,
TokenPayload
)

View File

@ -129,6 +129,7 @@ class InvoiceDB(InvoiceBase):
date_created: datetime
total_amount: float
status: str
user_id: int
items: List[InvoiceItemDB]
class Config:

19
app/schemas/token.py Normal file
View File

@ -0,0 +1,19 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
"""
Token schema for response
"""
access_token: str
token_type: str = "bearer"
class TokenPayload(BaseModel):
"""
Token payload schema for JWT
"""
sub: Optional[str] = None
exp: Optional[int] = None

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

@ -0,0 +1,67 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, validator
class UserBase(BaseModel):
"""
Base user schema with shared attributes
"""
email: EmailStr
username: str
is_active: Optional[bool] = True
full_name: Optional[str] = None
class UserCreate(UserBase):
"""
User creation schema
"""
password: str = Field(..., min_length=8)
@validator("password")
def password_strength(cls, v):
"""
Validate password strength
"""
if len(v) < 8:
raise ValueError("Password must be at least 8 characters long")
return v
class UserUpdate(BaseModel):
"""
User update schema
"""
email: Optional[EmailStr] = None
full_name: Optional[str] = None
password: Optional[str] = None
@validator("password")
def password_strength(cls, v):
"""
Validate password strength if provided
"""
if v is not None and len(v) < 8:
raise ValueError("Password must be at least 8 characters long")
return v
class UserInDB(UserBase):
"""
User schema as stored in the database
"""
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class User(UserInDB):
"""
User schema for API responses
"""
pass

View File

@ -0,0 +1,60 @@
"""add users and update invoices
Revision ID: b0e7512c61a3
Revises: ef0aaab3a275
Create Date: 2023-07-25 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b0e7512c61a3'
down_revision = 'ef0aaab3a275'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create users table
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('username', sa.String(length=50), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=100), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
# Create temporary table for invoices
with op.batch_alter_table('invoices', schema=None) as batch_op:
# Add user_id column with a default value of 1 (will be set properly later)
batch_op.add_column(sa.Column('user_id', sa.Integer(), nullable=True))
# Create foreign key (has to be done separately in SQLite)
with op.batch_alter_table('invoices', schema=None) as batch_op:
batch_op.create_foreign_key('fk_invoices_user_id', 'users', ['user_id'], ['id'])
# Make user_id not nullable after we've set up proper values
with op.batch_alter_table('invoices', schema=None) as batch_op:
batch_op.alter_column('user_id', nullable=False)
def downgrade() -> None:
# Drop foreign key constraint
with op.batch_alter_table('invoices', schema=None) as batch_op:
batch_op.drop_constraint('fk_invoices_user_id', type_='foreignkey')
batch_op.drop_column('user_id')
# Drop users table
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')

View File

@ -9,4 +9,7 @@ typing-extensions>=4.7.1
ruff>=0.0.292
python-dotenv>=1.0.0
jinja2>=3.1.2
aiofiles>=23.1.0
aiofiles>=23.1.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6