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
|
# 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
|
## Features
|
||||||
|
|
||||||
|
- User registration and authentication with JWT
|
||||||
- Generate invoices with automatic invoice number generation
|
- Generate invoices with automatic invoice number generation
|
||||||
- Store invoice details in SQLite database
|
- Store invoice details in SQLite database
|
||||||
- Retrieve invoice information by ID or invoice number
|
- Retrieve invoice information by ID or invoice number
|
||||||
- Update invoice details and status
|
- Update invoice details and status
|
||||||
- Delete invoices
|
- 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
|
- Web-based user interface for invoice management
|
||||||
- Health check endpoint
|
- 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
|
- **Database**: SQLite with SQLAlchemy ORM
|
||||||
- **Migrations**: Alembic
|
- **Migrations**: Alembic
|
||||||
- **Validation**: Pydantic
|
- **Validation**: Pydantic
|
||||||
|
- **Authentication**: JWT (JSON Web Tokens)
|
||||||
|
- **Password Hashing**: Bcrypt
|
||||||
- **Frontend**: Jinja2 Templates, HTML, CSS, JavaScript
|
- **Frontend**: Jinja2 Templates, HTML, CSS, JavaScript
|
||||||
- **Linting**: Ruff
|
- **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
|
- `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
|
### Invoice Management API
|
||||||
|
|
||||||
- `POST /api/v1/invoices`: Create a new invoice
|
All invoice endpoints require authentication with a valid JWT token in the Authorization header: `Authorization: Bearer {token}`
|
||||||
- `GET /api/v1/invoices`: List all invoices (with pagination)
|
|
||||||
|
- `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:
|
- Query parameters:
|
||||||
- `skip`: Number of records to skip (default: 0)
|
- `skip`: Number of records to skip (default: 0)
|
||||||
- `limit`: Maximum number of records to return (default: 100)
|
- `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")
|
- `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")
|
- `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)
|
- `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:
|
- Query parameters:
|
||||||
- `fields`: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount")
|
- `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:
|
- Query parameters:
|
||||||
- `invoice_number`: Invoice number to search for (required)
|
- `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")
|
- `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}`: Update an invoice (must belong to the authenticated user)
|
||||||
- `PATCH /api/v1/invoices/{invoice_id}/status`: Update invoice status
|
- `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
|
- `DELETE /api/v1/invoices/{invoice_id}`: Delete an invoice (must belong to the authenticated user)
|
||||||
|
|
||||||
### Frontend Routes
|
### Frontend Routes
|
||||||
|
|
||||||
@ -116,6 +136,69 @@ This is a FastAPI application for generating and managing invoices. It allows yo
|
|||||||
uvicorn main:app --reload
|
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
|
## API Documentation
|
||||||
|
|
||||||
Once the application is running, you can access:
|
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 fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import invoices
|
from app.api.routes import invoices, auth
|
||||||
|
|
||||||
api_router = APIRouter()
|
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 import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.utils import generate_invoice_number
|
from app.core.utils import generate_invoice_number
|
||||||
from app.models.invoice import Invoice, InvoiceItem
|
from app.models.invoice import Invoice, InvoiceItem
|
||||||
|
from app.models.user import User
|
||||||
from app.schemas.invoice import (
|
from app.schemas.invoice import (
|
||||||
InvoiceCreate,
|
InvoiceCreate,
|
||||||
InvoiceDB,
|
InvoiceDB,
|
||||||
@ -22,9 +24,15 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=InvoiceDB, status_code=status.HTTP_201_CREATED)
|
@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.
|
Create a new invoice.
|
||||||
|
|
||||||
|
Requires authentication. The invoice will be associated with the current user.
|
||||||
"""
|
"""
|
||||||
# Generate unique invoice number
|
# Generate unique invoice number
|
||||||
invoice_number = generate_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,
|
notes=invoice_data.notes,
|
||||||
status="PENDING",
|
status="PENDING",
|
||||||
total_amount=0, # Will be calculated from items
|
total_amount=0, # Will be calculated from items
|
||||||
|
user_id=current_user.id, # Associate with current user
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to DB
|
# 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_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"),
|
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.
|
Retrieve a list of invoices with advanced filtering and sorting options.
|
||||||
@ -141,8 +151,8 @@ def get_invoices(
|
|||||||
sort_order=sort_order,
|
sort_order=sort_order,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build the query with filters
|
# Build the query with filters - only get invoices for the current user
|
||||||
query = db.query(Invoice)
|
query = db.query(Invoice).filter(Invoice.user_id == current_user.id)
|
||||||
|
|
||||||
# Apply status filter if provided
|
# Apply status filter if provided
|
||||||
if status:
|
if status:
|
||||||
@ -223,11 +233,14 @@ def get_invoices(
|
|||||||
def get_invoice(
|
def get_invoice(
|
||||||
invoice_id: int,
|
invoice_id: int,
|
||||||
fields: Optional[str] = None,
|
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.
|
Retrieve a specific invoice by ID.
|
||||||
|
|
||||||
|
The invoice must belong to the authenticated user.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
- invoice_id: ID of the invoice to retrieve
|
- 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")
|
- 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",
|
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 parameter is provided, filter the response
|
||||||
if fields:
|
if fields:
|
||||||
# Process the response to include only the specified fields
|
# Process the response to include only the specified fields
|
||||||
@ -254,17 +274,25 @@ def get_invoice(
|
|||||||
def find_invoice_by_number(
|
def find_invoice_by_number(
|
||||||
invoice_number: str,
|
invoice_number: str,
|
||||||
fields: Optional[str] = None,
|
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.
|
Find an invoice by its invoice number.
|
||||||
|
|
||||||
|
The invoice must belong to the authenticated user.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
- invoice_number: The invoice number to search for
|
- 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")
|
- 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.
|
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:
|
if invoice is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
@ -285,10 +313,13 @@ def find_invoice_by_number(
|
|||||||
def update_invoice(
|
def update_invoice(
|
||||||
invoice_id: int,
|
invoice_id: int,
|
||||||
invoice_update: InvoiceUpdate,
|
invoice_update: InvoiceUpdate,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update an existing invoice.
|
Update an existing invoice.
|
||||||
|
|
||||||
|
The invoice must belong to the authenticated user.
|
||||||
"""
|
"""
|
||||||
# Get the invoice
|
# Get the invoice
|
||||||
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
|
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",
|
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 invoice fields
|
||||||
update_data = invoice_update.dict(exclude_unset=True)
|
update_data = invoice_update.dict(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
@ -314,10 +352,13 @@ def update_invoice(
|
|||||||
def update_invoice_status(
|
def update_invoice_status(
|
||||||
invoice_id: int,
|
invoice_id: int,
|
||||||
status_update: InvoiceStatusUpdate,
|
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.
|
Update the status of an invoice.
|
||||||
|
|
||||||
|
The invoice must belong to the authenticated user.
|
||||||
"""
|
"""
|
||||||
# Get the invoice
|
# Get the invoice
|
||||||
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
|
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",
|
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
|
# Update status
|
||||||
invoice.status = status_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)
|
@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.
|
Delete an invoice.
|
||||||
|
|
||||||
|
The invoice must belong to the authenticated user.
|
||||||
"""
|
"""
|
||||||
# Get the invoice
|
# Get the invoice
|
||||||
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
|
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",
|
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
|
# Delete the invoice
|
||||||
db.delete(invoice)
|
db.delete(invoice)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
|
import secrets
|
||||||
|
|
||||||
from pydantic import AnyHttpUrl, validator
|
from pydantic import AnyHttpUrl, validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
@ -12,6 +13,16 @@ class Settings(BaseSettings):
|
|||||||
# CORS settings
|
# CORS settings
|
||||||
BACKEND_CORS_ORIGINS: List[Union[str, AnyHttpUrl]] = []
|
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)
|
@validator("BACKEND_CORS_ORIGINS", pre=True)
|
||||||
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
|
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
|
||||||
if isinstance(v, str) and not v.startswith("["):
|
if isinstance(v, str) and not v.startswith("["):
|
||||||
|
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
|
@ -28,9 +28,15 @@ class Invoice(Base):
|
|||||||
status = Column(String(50), default="PENDING", nullable=False)
|
status = Column(String(50), default="PENDING", nullable=False)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Foreign key to user
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
|
|
||||||
# Relationship with invoice items
|
# Relationship with invoice items
|
||||||
items = relationship("InvoiceItem", back_populates="invoice", cascade="all, delete-orphan")
|
items = relationship("InvoiceItem", back_populates="invoice", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
# Relationship with user
|
||||||
|
user = relationship("User", back_populates="invoices")
|
||||||
|
|
||||||
|
|
||||||
class InvoiceItem(Base):
|
class InvoiceItem(Base):
|
||||||
__tablename__ = "invoice_items"
|
__tablename__ = "invoice_items"
|
||||||
|
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,
|
InvoiceItemCreate,
|
||||||
InvoiceItemDB,
|
InvoiceItemDB,
|
||||||
InvoiceSearchQuery,
|
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
|
date_created: datetime
|
||||||
total_amount: float
|
total_amount: float
|
||||||
status: str
|
status: str
|
||||||
|
user_id: int
|
||||||
items: List[InvoiceItemDB]
|
items: List[InvoiceItemDB]
|
||||||
|
|
||||||
class Config:
|
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')
|
@ -10,3 +10,6 @@ ruff>=0.0.292
|
|||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
jinja2>=3.1.2
|
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