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:
parent
040210f43f
commit
4e361e2d61
99
README.md
99
README.md
@ -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
69
app/api/deps.py
Normal 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
|
@ -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
110
app/api/routes/auth.py
Normal 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
|
@ -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()
|
||||
|
@ -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
57
app/core/security.py
Normal 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
|
@ -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
|
@ -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
22
app/models/user.py
Normal 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")
|
@ -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
|
||||
)
|
@ -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
19
app/schemas/token.py
Normal 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
67
app/schemas/user.py
Normal 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
|
@ -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')
|
@ -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
|
Loading…
x
Reference in New Issue
Block a user