diff --git a/README.md b/README.md index 1ff1777..72dd3ae 100644 --- a/README.md +++ b/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: diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..62808a2 --- /dev/null +++ b/app/api/deps.py @@ -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 \ No newline at end of file diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py index cbb8e88..9b56717 100644 --- a/app/api/routes/__init__.py +++ b/app/api/routes/__init__.py @@ -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"]) \ No newline at end of file +api_router.include_router(invoices.router, prefix="/invoices", tags=["invoices"]) +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) \ No newline at end of file diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py new file mode 100644 index 0000000..52c2bef --- /dev/null +++ b/app/api/routes/auth.py @@ -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 \ No newline at end of file diff --git a/app/api/routes/invoices.py b/app/api/routes/invoices.py index 31556dc..31adc33 100644 --- a/app/api/routes/invoices.py +++ b/app/api/routes/invoices.py @@ -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() diff --git a/app/core/config.py b/app/core/config.py index 1b7c044..ffdb8e2 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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]: diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..bd191ab --- /dev/null +++ b/app/core/security.py @@ -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 \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py index 717fe72..4a625d6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1 +1,2 @@ -from app.models.invoice import Invoice, InvoiceItem # noqa: F401 \ No newline at end of file +from app.models.invoice import Invoice, InvoiceItem # noqa: F401 +from app.models.user import User # noqa: F401 \ No newline at end of file diff --git a/app/models/invoice.py b/app/models/invoice.py index d6e1305..538e5dc 100644 --- a/app/models/invoice.py +++ b/app/models/invoice.py @@ -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): diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..f64b6b1 --- /dev/null +++ b/app/models/user.py @@ -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") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 902c537..6485953 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -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 ) \ No newline at end of file diff --git a/app/schemas/invoice.py b/app/schemas/invoice.py index 587befa..5c08e55 100644 --- a/app/schemas/invoice.py +++ b/app/schemas/invoice.py @@ -129,6 +129,7 @@ class InvoiceDB(InvoiceBase): date_created: datetime total_amount: float status: str + user_id: int items: List[InvoiceItemDB] class Config: diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..64938e6 --- /dev/null +++ b/app/schemas/token.py @@ -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 \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..fba5f60 --- /dev/null +++ b/app/schemas/user.py @@ -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 \ No newline at end of file diff --git a/migrations/versions/b0e7512c61a3_add_users_and_update_invoices.py b/migrations/versions/b0e7512c61a3_add_users_and_update_invoices.py new file mode 100644 index 0000000..b0cce9b --- /dev/null +++ b/migrations/versions/b0e7512c61a3_add_users_and_update_invoices.py @@ -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') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ad7870d..bc2e47b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +aiofiles>=23.1.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +python-multipart>=0.0.6 \ No newline at end of file